API Contract System Design
One response envelope, one table of error codes, and the reason a client written against one endpoint works against all of them.
internal/respondinternal/errorcodesinternal/access1.Problem statement
Six clients are built against this API: an admin panel, a web app, an Expo app, a desktop client, a generated SDK and whatever an operator writes next. Each one has code that reads a response, finds the data, finds the error, and decides what to show.
If two endpoints answer in different shapes, that code has to handle both. In practice it does not: it handles whichever shape the developer was looking at, and the other one produces a blank screen. A handler answering in a different shape does not fail a test. It fails one screen, usually the one nobody opened before release.
The subtle version of the problem is not the shape, it is the status codes, and specifically having two tables of them. The named helpers each carried a status, and a generated code table carried the same pairs, which is how one error code came back as 422 from a helper and 400 from a handler that wrote its own envelope. A client branching on the status saw two different outcomes for the same condition.
There are also two distinctions that get muddled everywhere and are worth deciding once. 400 against 422: cannot parse against understood and refused. And 403 against 404 for a record somebody may not see, where 403 confirms the record exists and is therefore an information leak on any endpoint keyed by a guessable identifier.
The system has to be able to:
- Answer every request in one envelope: data and message, or data and meta, or error.
- Carry a stable machine-readable code on every error, from one catalogue.
- Derive the status from the code, in one place, so the same condition always has the same status.
- Put per-field messages where a form can place them under their input.
- Never leak internal detail in a 500, while giving the operator the cause and a request identifier.
- Answer 404 rather than 403 for a record the caller may not see.
- Describe which routes exist and what each demands of a caller, as data.
- Generate a client from the contract rather than by hand.
2.System requirements
Functional requirements
- An object at the top level of every response, never a bare array or string.
- Single reads: data, with an optional human message.
- Lists: data and meta, with total, page, page_size and pages always present.
- Cursor lists: next_cursor, has_more and mode.
- Errors: error with code, message and optional per-field details.
- A catalogue of error codes, each with its status, generating the code table.
- Response helpers, one per status, reading the status from the catalogue.
- A generated access table: every route, and what it demands of a caller.
- A version prefix on every route, from one constant.
Non-functional requirements
- One table of code to status. there were two, which is how one code answered 422 from a helper and 400 from a hand-written envelope. One catalogue, and no handler writes an error body by hand.
- Always an object. an array at the top level cannot gain a meta later without breaking every caller. The envelope is what makes the API extensible.
- Zero is an answer. the pagination counts are always present. A response that omits total leaves every client doing arithmetic on undefined, which renders as a blank card rather than a nought.
- Codes are stable, messages are not. clients branch on the code. The message is one sentence for a person and may change without notice.
- A 500 never explains itself. a driver error can describe the schema and sometimes contains SQL. The caller gets a code and a sentence; the operator gets the cause and the request identifier that ties a report to a log line.
- 404 over 403 for records. a 403 confirms existence. 403 is reserved for an action the caller may not perform on a record they can already see.
3.Capacity estimation
Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.
Assumptions
| Parameter | Value |
|---|---|
| Clients built against the contract | 6 |
| Endpoints in a mature project | 200 to 500 |
| Error codes in the catalogue | ~40 |
| Envelope overhead | ~40 bytes per response |
What consistency is worth
The saving is not bytes, it is that a client can be written once. Six clients times two hundred endpoints is where inconsistency actually costs.
Envelope overhead
The cost of two status tables
This is the kind of defect that gets reported as "sometimes the form does not show the error", investigated three times and closed as unreproducible.
4.High level design
A catalogue generates a code table. Helpers read the table. Nothing writes an envelope by hand.
Core components
- The envelope. data and message, data and meta, or error. Three shapes, and an object at the top level in all of them.
- Error code catalogue. the one table of code to status. It generates the code table the runtime uses, which is what stops a second table existing.
- Response helpers. one per status, each a line over a common failure writer, each reading its status from the catalogue.
- Validation details. a field name to a sentence, so a form puts each message under its own input instead of showing one summary.
- Access table. generated data describing every route and what it demands: authentication, a role, a permission, or nothing.
- Version prefix. one constant. A prefix written out per route group is a prefix that will be inconsistent.
- Generated client. the TypeScript types, the Zod schemas and the hooks, produced from the same definitions as the handlers.
Request flow
An error, from a handler to a form field
- 1A request arrives with a value that is well formed and wrong: an email already taken, a total that does not balance.
- 2The handler calls a response helper. It does not write an envelope, because a hand-written envelope is how a code came to carry two different statuses.
- 3The helper reads the status for its code out of the catalogue. The catalogue is the only table of code to status, which is the single most important property of the whole arrangement.
- 4The status is 422, because the request was understood and the answer is no. A 400 would mean it could not be parsed, and the distinction is the one that gets muddled most often.
- 5The envelope is written: a code the client branches on, a sentence for a person, and a map of field names to their own sentences.
- 6Every client has one function that reads this. It finds the code in the same place for every endpoint, which is what makes writing six clients tractable.
- 7The per-field details let the form put each message under its own input. A single summary message is the alternative, and it is the version users cannot act on.
- 8In the 500 case this diverges: the caller gets a generic sentence while the operator gets the real error logged with the method, the path and the request identifier. A driver error can describe the schema and sometimes contains SQL, so it never goes out.
Data flow
- The code is for machines, the message is for people. A client that branches on message text breaks the first time somebody improves the wording.
- A record the caller may not see answers 404. A 403 confirms it exists, which is an information leak on any endpoint keyed by a guessable identifier.
- The pagination counts are always present, including zero, because a missing count is worse for every client than a zero one.
- The access table is generated, so the answer to "what does this route require" is data rather than a reading of middleware chains.
5.Technology stack
| Component | What it is |
|---|---|
| Envelope | data and message, data and meta, or error |
| Error codes | screaming snake case, from one catalogue |
| Status mapping | generated from the catalogue, one table |
| Helpers | one per status, no hand-written error bodies |
| Versioning | a path prefix from one constant |
| Access | a generated table of route requirements |
| Client | generated types, schemas and hooks |
6.API design
The status codes, and when each one is right
| Method | Endpoint | What it does |
|---|---|---|
| GET | 200 | Read, update, delete, and anything else that worked |
| POST | 201 | A record was created |
| POST | 400 | Cannot be parsed, or a required parameter is absent |
| GET | 401 | No credentials, or they are not valid |
| POST | 403 | An action this caller may not perform on a record they can see |
| GET | 404 | No such record, or none this caller may see |
| PUT | 409 | A duplicate, or a version mismatch |
| POST | 422 | Understood, and the answer is no |
| GET | 429 | Rate limited |
| GET | 500 | We broke, and the response does not say how |
The helpers, which are the only way an envelope is written
respond.OK(c, user) // 200 { data }respond.OK(c, user, "Saved") // 200 { data, message }respond.Created(c, user, "User created") // 201respond.BadRequest(c, "page must be a number") // 400respond.Unauthorized(c, "") // 401respond.Forbidden(c, "You cannot publish") // 403respond.NotFound(c, "User not found") // 404respond.Conflict(c, "That email is already taken") // 409respond.Validation(c, "Check the form", fields) // 422respond.Internal(c, err) // 500, logged, not explained
An error a form can act on
{"error": {"code": "VALIDATION_ERROR","message": "Check the form","details": {"email": "That email is already taken","total": "Debits and credits do not balance"}}}
7.Low level design
Core types
The one table of code to status. It generates the runtime code table, which is the mechanism that prevents a second table existing.
One per status, each a line over a shared failure writer. Short enough that writing an envelope by hand is never the easier option.
Logs the error with the method, path and request identifier, and sends a generic message. The asymmetry is deliberate: the operator gets the cause, the caller gets a code.
Generated. Every route and what it demands of a caller, as data, so the question can be answered without reading middleware chains.
Design principles applied
- One table, not two. the duplicate status table is the defect this whole design is organised around. One code, one status, one place.
- Always an object. a top-level array cannot gain a sibling field later. The envelope is what makes the API able to change.
- Codes for machines, messages for people. the code is a contract and the message is copy. Keeping them separate lets the copy improve without breaking a client.
- 404 by default for records. existence is information. The leak is free to prevent and expensive to notice.
- Never explain a 500. the detail that helps an operator is the detail that helps an attacker. The request identifier is how both get what they need.
Patterns
| Pattern | Where it is used |
|---|---|
| Envelope | one shape for every response |
| Single source of truth | a catalogue generating the status table |
| Facade | helpers in front of the envelope |
| Code-first client generation | types and schemas from the handlers’ definitions |
8.Scalability and performance
- A consistent contract is what makes a second and third client cheap. Six clients against one shape is one reader written six times; against many shapes it is unbounded.
- Envelope overhead is negligible and compresses away. Nothing about consistency costs throughput.
- The version prefix is what allows a breaking change: a second version alongside the first, rather than a flag day.
- A generated client means a contract change is a compile error in every client that uses it, which is the only mechanism that scales past two consumers.
- The access table makes authorisation auditable as data, which is what lets a security review be a query rather than a reading exercise.
- The limit is discipline: one hand-written envelope reintroduces the inconsistency, which is why there are helpers for every case and a rule against writing error bodies directly.
9.Bottlenecks and improvements
What breaks first
- Hand-written envelopes. one handler writing its own error body is how the second status table came back, and it is invisible until a client branches on the status.
- The 400 against 422 muddle. both are client errors and the distinction is semantic, so they get used interchangeably and clients cannot rely on either.
- Breaking changes. a field removed or a type changed breaks every client, and the version prefix is the only escape.
- Message text as a contract. a client branching on message text breaks the first time the copy is improved, and the break is in the client rather than where the change was made.
- Pagination counts omitted. a missing total becomes undefined in arithmetic, which renders as a blank card rather than a zero and looks like a data problem.
What to do about it
- Lint for hand-written error bodies. a check that no handler writes a JSON error directly. It is a grep, and it protects the property the whole contract rests on.
- Write the distinction down and test it. contract tests asserting a malformed body is 400 and a failed rule is 422, per endpoint. The semantics then stop being a judgement call.
- Version additively. add fields, never remove or retype. A new version only for changes that cannot be additive.
- Generate the client from the contract. then a contract change is a compile error rather than a runtime surprise in a client nobody is looking at.
- Assert the counts in a test. a list test that checks total, page, page_size and pages are present on an empty result. One test, and it covers the whole class of blank-card bugs.
