Skip to content

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 return details: [{ path, message }].)
  • The genuine wire-level variance is narrow: a few list endpoints bypass createListSuccessResponse (auth/users hand-rolls pagination, staff/search returns { results }, products returns 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. total is 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+json is 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 (errortype, messagetitle/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:

  1. Conform the list-endpoint outliers (auth/users, staff/search, products) to createListSuccessResponse as each is touched.
  2. Standardize service paginated returns to { items, total } per-domain as each domain comes up (internal; no wire change).
  3. Pin the canonical shapes (incl. the validation details shape and 9457-adjacency note) in the conventions docs.
  4. 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

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