All systems
Platform

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/access

1.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

ParameterValue
Clients built against the contract6
Endpoints in a mature project200 to 500
Error codes in the catalogue~40
Envelope overhead~40 bytes per response

What consistency is worth

6 clients x 1 response reader each, not 6 x N shapes
1 error handler per client, branching on ~40 known codes
instead of per-endpoint special cases

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

~40 bytes of wrapper per response
at 926 req/s = ~37 KB/s
under gzip, effectively nothing

The cost of two status tables

1 code answering 2 different statuses
x every client branching on status
= an intermittent bug per client

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

12345678RequestHandlerrespond.ValidationCode CatalogueStatus 422Error EnvelopeClient ReaderMessage Under InputRequest ID + Log
  1. 1A request arrives with a value that is well formed and wrong: an email already taken, a total that does not balance.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

ComponentWhat it is
Envelopedata and message, data and meta, or error
Error codesscreaming snake case, from one catalogue
Status mappinggenerated from the catalogue, one table
Helpersone per status, no hand-written error bodies
Versioninga path prefix from one constant
Accessa generated table of route requirements
Clientgenerated types, schemas and hooks

6.API design

The status codes, and when each one is right

MethodEndpointWhat it does
GET200Read, update, delete, and anything else that worked
POST201A record was created
POST400Cannot be parsed, or a required parameter is absent
GET401No credentials, or they are not valid
POST403An action this caller may not perform on a record they can see
GET404No such record, or none this caller may see
PUT409A duplicate, or a version mismatch
POST422Understood, and the answer is no
GET429Rate limited
GET500We 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") // 201
respond.BadRequest(c, "page must be a number") // 400
respond.Unauthorized(c, "") // 401
respond.Forbidden(c, "You cannot publish") // 403
respond.NotFound(c, "User not found") // 404
respond.Conflict(c, "That email is already taken") // 409
respond.Validation(c, "Check the form", fields) // 422
respond.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

errorcodes.Cataloginternal/errorcodes/catalog.go

The one table of code to status. It generates the runtime code table, which is the mechanism that prevents a second table existing.

respond helpers

One per status, each a line over a shared failure writer. Short enough that writing an envelope by hand is never the easier option.

respond.Internal

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.

access.Tableinternal/access/access.go

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

PatternWhere it is used
Envelopeone shape for every response
Single source of trutha catalogue generating the status table
Facadehelpers in front of the envelope
Code-first client generationtypes 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.

Read next