ADR-019: API Pattern Alignment — Pragmatic In-Place Convergence
Status
Accepted (2026-06-08).
Supersedes ADR-014 (never left Proposed). Amends the rollout posture of ADR-009, ADR-010, ADR-011: their target patterns are retained, but the implied bulk migration is cancelled — convergence is forward-and-at-seams only. Complements ADR-017, which killed the platform-v2 framework rewrite.
Context
v1 (the only live API — see ADR-017) grew organically during bootstrap. It has real inconsistency in places, but less than prior docs claimed. An audit of the current code (2026-06-08) found:
- The success/error envelope is already uniform — every route returns
{ success, data }/{ success, error, message, status }via one factory. Earlier audits overstated the drift. - Errors are centralized — every error funnels through one formatter (
formatErrorResponse/AppError). There is no per-route error drift to fix. (The staff-ui-redesign spec's claim that validation errors return{ errors: [...] }is wrong; they returndetails: [{ path, message }].) - The genuine wire-level variance is narrow: a few list endpoints bypass
createListSuccessResponse(auth/usershand-rolls pagination,staff/searchreturns{ results },productsreturns raw service output), and services return paginated data in per-domain shapes internally ({ resources, totalItems },{ users, totalCount }, …). - Everything else flagged by ADR-009/010/011 (service object-vs-class, file locations, route-path fragmentation) is internal or cosmetic — invisible to API consumers.
The prior standardization efforts all failed for the same reason: they were framed as whole-codebase migrations (platform-v2: a new framework + hard cutover; ADR-009/010/011/014: accepted but never implemented, now stale). Two consumers are coming that make some alignment worthwhile — the staff UI rewrite (features/staff-ui-redesign) and the staff MCP (features/staff-mcp) — both consuming the API's read and write surface.
The operative constraints are unusual and drive the decision:
- One developer; every line authored by Claude Code, indefinitely. Conventional architecture goals (onboarding, team coordination, bus factor) don't apply.
- Authoring is ~free; human verification is the bottleneck, and it does not get faster. The cost of any change is the verification it forces — maximal on working money paths, near-zero on new code.
- One self-deployed consumer base. API and its callers ship together; two contract versions never need to be live at once.
Decision
Consistency is a property of forward-authoring and the consumer boundary — not a retrofit
We do not run an API standardization project. We never sweep existing routes for consistency. Convergence happens two ways, both near-free:
- Forward — new routes follow the conventions. Free; the code is being written fresh anyway.
- At seams — when a real reason already has us editing a route (a consumer needs it, or a bug), we conform it then, because we're verifying it in that moment regardless.
A pre-emptive migration of working routes is rejected outright. It is the platform-v2 mistake at smaller scale: each route is a verification job whether Claude writes it in one second or one hour. The industry term for the posture we are taking is incremental/strangler-style replacement (incremental is the standard for mission-critical APIs; big-bang is reserved for small systems) — but we adopt only its principle. We do not add a façade/proxy: there is no second system to route between, and Next.js file-based routing already gives us per-route independent replacement for free.
Local conventions vs global contracts — the rule that determines how to converge
The single most useful distinction:
- Local conventions — invisible outside one route: service shape (plain object), validation style, auth-check idiom, file location, naming. → Forward + seams is safe. New code may be "better" than old; the old routes can stay as-is forever. (These are the ADR-009/010/011 targets — retained as targets, never as a migration.)
- Global contracts — the wire shape every consumer parses: success envelope, error shape, pagination shape. → Must be uniform. New code matches the existing shape; it never introduces a "better" variant unilaterally, because that creates the inconsistency we're avoiding. A global contract changes only by a coordinated all-consumers cutover, or not at all.
The global-contract decisions (canonical for all v1 routes)
Concrete shapes live in engineering/architecture/api/response-envelopes.md and error-handling.md. The decisions and their rationale:
- Keep the
{ success, data }envelope. Do not go naked-wire — not even on new routes. Naked-resource + status-code is the 2026 greenfield default, except where all consumers are internal and already standardized on one envelope — the explicitly sanctioned carve-out, and exactly our case. We already have it everywhere; changing it is pure consumer-side churn for an aesthetic gain. Half-naked (new routes naked, old enveloped) is strictly worse than either pure option. - Offset pagination with totals, not cursor. Offset is the right tool for admin tables: page numbers, jump-to-page, total counts — all of which the staff UI needs and cursor pagination cannot easily provide. Our largest table (~10k rows) never hits offset's deep-page cost.
totalis per-endpoint opt-out-able, since the count is a second full scan and could be dropped for a genuinely large table without a contract change. - One error shape, kept as-is for now; problem+json deferred. RFC 9457
application/problem+jsonis the 2026 standard (Stripe, GitHub, Cloudflare) and the guidance extends to internal APIs. We are choosing not to adopt it yet — a cost decision made with eyes open, not a claim we're more correct than the RFC. Rationale: our errors are already uniform; no consumer is pulling the standard; and our shape is already ~isomorphic to 9457 (error→type,message→title/detail,status,details→extension), so the future switch is a one-formatter swap plus a coordinated consumer-parser update, not a route-by-route rewrite. Because errors are centralized, there is no incremental path anyway — adoption is binary. Trigger to adopt: we expose a public/partner API, or we allocate deliberate bandwidth to verify an all-consumers error cutover. Until then, the current shape is canonical and the conventions doc notes its 9457-adjacency.
No URL versioning
No /api/v1, /api/v2. URL versioning solves breaking changes across external consumers you cannot migrate together — a problem a single self-deployed consumer base never has. Best practice for first-party single-consumer APIs is to evolve in place and version only when a breaking change becomes unavoidable. Adding version prefixes would also institutionalize the permanent two-contract fork we are explicitly avoiding. "v1/v2" in our vocabulary refers to internal implementation patterns, never a URL-exposed contract version; there is exactly one, unversioned API.
Money paths stay frozen
Stripe webhooks, checkout, payment-intent creation, refund execution, and subscription-transfer logic are not touched for consistency. They change only when a bug already has us inside them, one at a time, with deliberate verification. This is distinct from ordinary entity CRUD (edit user, create resource), which is normal seam work.
The actual near-term worklist
Small and bounded — done forward + at seams, pulled by the staff UI / MCP work, not as a standalone project:
- Conform the list-endpoint outliers (
auth/users,staff/search,products) tocreateListSuccessResponseas each is touched. - Standardize service paginated returns to
{ items, total }per-domain as each domain comes up (internal; no wire change). - Pin the canonical shapes (incl. the validation
detailsshape and 9457-adjacency note) in the conventions docs. - Substrate upgrade (Node, Stripe SDK, ESM, etc. — ADR-017's PR-A, never landed) is a separate track on its own EOL-driven clock, unbundled from any pattern work.
Alternatives considered
- Adopt the platform-v2 framework (
defineRoute, naked-wire, cursor, problem+json). Genuinely better patterns in the abstract, wrong for the constraints: cursor-only fights the admin UI, naked-wire + problem+json force rewriting every consumer, and it reintroduces the permanent v1/v2 fork ADR-017 killed. Steal its good local ideas (Zod-schema-per-endpoint, typed errors) as forward conventions; leave the framework and the contract changes. - Do nothing. Tenable, but Claude pattern-matches the wrong example and propagates drift, and the two incoming consumers genuinely benefit from a uniform read/write surface. The forward convention + lint stops the drift at near-zero cost.
- In-place bulk migration of all routes. Same verification tax as platform-v2 with a smaller blast radius. Rejected.
- Adopt problem+json now. See the error decision above — deferred, not rejected; recorded with an explicit trigger.
Consequences
Benefits:
- No standardization project to starve customer work; alignment rides work already being verified.
- New code is consistent by default (conventions doc + lint); existing working code is left alone.
- The staff UI and MCP get a uniform read/write surface where they actually touch it, enabling generic hooks and generic tool-wrapping.
- The riskiest code (money paths) is never disturbed for cosmetics.
Tradeoffs:
- The codebase will not be uniformly "clean." Old routes keep their local idioms indefinitely. Correct, but it offends a tidiness instinct.
- We knowingly run an error contract that differs from the 2026 standard. Accepted as a documented, cheap-to-reverse deferral.
Risks:
- "Forward + seams" can drift back into ad-hoc bulk migration if not actively resisted. This ADR is the load-bearing decision: when in doubt, leave the route alone.
- A future need for problem+json or URL versioning could arrive. Both are cheap to adopt when genuinely triggered (error shape is 9457-adjacent; versioning is additive); neither is worth pre-building.
References
- ADR-009, ADR-010, ADR-011 — target local conventions (rollout amended here)
- ADR-014 — superseded; its core finding (uniform envelope,
{ items, total }service shape) is retained - ADR-017 — platform-v2 framework cancelled; substrate deferred
engineering/architecture/api/— canonical shapes (response envelopes, error handling, validation, route handlers)features/staff-ui-redesign,features/staff-mcp— the consumers pulling alignment
Research backing the contract decisions (2026)
Envelope vs naked-wire (carve-out for internal/standardized consumers): - RFC 9457: Problem Details for HTTP APIs - ASP.NET Core API Response Standardization — Enterprise Decision Guide - Best Practices for a Pragmatic RESTful API — Vinay Sahni
Offset-for-admin-tables (page numbers, jump-to-page, totals; offset fine under ~10k rows): - A Developer's Guide to API Pagination: Offset vs Cursor — Gusto Embedded - Pagination Patterns — Cursor vs Offset vs Keyset (2026) - Cursor vs offset pagination for fast admin screen APIs — AppMaster
No premature versioning for single self-deployed consumer: - REST API Versioning: Definition, Strategies & Best Practices (2026) — DigitalAPI - Versioning Best Practices in REST API Design — Speakeasy - Web API Design Best Practices — Microsoft Azure Architecture Center
Incremental/strangler over big-bang (principle only — no proxy): - Strangler Fig Pattern — Microsoft Azure Architecture Center - Strangler Fig Pattern for API Versioning — Zuplo
RFC 9457 problem+json (the deferred standard; now widely adopted incl. internal APIs): - Problem Details (RFC 9457): Doing API Errors Well — Swagger - Problem Details (RFC 9457): Getting Hands-On — Swagger