Skip to content

API design

Most API mistakes I’ve made or reviewed came from designing endpoints before I understood the domain. Two rules that would have saved me years:

Design the contract before the code

The API contract is the product boundary. Every consumer — mobile app, admin panel, third party, the AI feature you’ll ship next quarter — depends on it. Change it casually and you break everyone.

I write the OpenAPI schema (or the tRPC procedure signature, or the GraphQL type) first, get it reviewed, then generate the types and write the handler. When the contract is the source of truth:

  • Frontend can build against a mock before the backend is ready.
  • Consumers see breaking changes at PR time, not in production.
  • The types in my handler and the types in the client are the same types. Drift becomes structurally impossible, not culturally prevented.

The failure mode I’ve seen most: types are written by hand, kept “synced” by convention, and drift within a sprint. Don’t sync — generate.

Model state machines, not CRUD endpoints

Most business objects are not rows to be updated. They’re state machines. A subscription is not “a row with a status field”; it’s active → past_due → grace_period → cancelled → reactivated, with rules about which transitions are legal from which state, and what happens to related objects (renewals, refunds, entitlements) at each one.

If your API is PATCH /subscriptions/:id { status: 'cancelled' }, you’ve handed every caller the ability to put your system in any state they want, in any order. What you actually want is POST /subscriptions/:id/cancel — an action verb, with the server deciding what “cancel” means for the current state. The same instinct applies on the frontend — see frontend architecture.

I learned this the hard way on Carizmo. The original subscription model had washesRemaining and a nextWashDate, and it looked clean in the ERD. What it didn’t model: what happens when a wash is attempted but fails (car covered, gate locked, customer not home). The row shape said “it happened or it didn’t.” Reality had five other states. We ended up rewriting the subscription and job models to be explicit state machines with named transitions. The lesson: if you can’t draw the state diagram on a whiteboard, your API is going to lie about what your system actually does.

A convention worth trying, not a rule

For internal APIs where I own both sides, I’ve had good results with GET for reads and POST /resources/<verb> for writes — one endpoint per action, each with its own schema. Trades REST purity for grep-ability. For public APIs where consumers expect REST, be REST.

Error codes: keep the HTTP set small, put the “why” in the body

Most APIs I’ve owned settle on nine HTTP status codes: 200, 201, 400, 401, 403, 404, 409, 422, 429, 500. Each of those carries meaning that infra tooling reads directly — 429 in particular you can’t bury in a body payload without breaking every rate-limit-aware client. 409 and 422 do real work distinguishing “conflict with current state” from “well-formed but semantically invalid.”

Anything more granular than that goes in a numeric error code in the response body: { error: { code: 4012, message: "..." } }. HTTP status is the transport-layer signal — the domain-level “why” is a business concern and belongs in the payload. Consumers switch on numbers cleanly; they can’t switch on prose.