Appearance
9.6.1 — API Design: REST & Resource Modeling
You can rename a private function any afternoon you like. You cannot rename a URL that forty other teams already call. That is the whole difference between the code inside your service and the API on its edge, and it is why 3.6.1 made the same point about JavaScript: once an interface has users you do not control, its mistakes are permanent. Every shortcut you take on the shape of an endpoint gets paid for by strangers, forever, in code you will never see. ⚑API Design fundamentals. [EQ-963]
1. What REST actually is (and what your API actually is)
REST stands for Representational State Transfer. Roy Fielding wrote it up in his 2000 PhD thesis, and the thing people usually miss is what he was actually doing. He was not inventing a way to build APIs. He was writing down, after the fact, the reasons the web had already grown to a size nobody planned for. REST is a description of a working system, which is why its rules sound less like syntax and more like physics.
There are five constraints that matter to you, and each one buys a specific thing:
- Client and server are separate. The browser and the server can be rewritten by different people on different schedules, as long as the messages between them stay the same. You get to redeploy your backend without shipping a new app.
- The server is stateless. Every request carries everything needed to answer it. The server remembers nothing between one request and the next. This sounds like a restriction and it is, but look at what it buys: if no server holds any memory of you, then any server can answer your next request. That is the entire foundation of running twenty copies of your service behind a load balancer (Part 10.2), and it is why your login token rides along on every single request instead of being remembered once (Part 8.4).
- Responses say whether they can be cached. A response is not just data; it also states how long that data stays good. Everything in the caching machinery of [5.6] hangs off this one habit.
- The interface is uniform. A small fixed set of verbs, applied to things that have addresses. The thing itself (the order in your database) and the representation you send back (a blob of JSON describing it) are deliberately different objects. You can change how orders are stored without changing the JSON.
- The system can be layered. A client talks to what it thinks is the server, but a proxy, a gateway, or a cache may be sitting in the middle. None of them break anything, because they only need to understand the same uniform interface. This is 9.4.9's wrapper idea, except the wrappers have IP addresses.
Now the honest part. Almost nobody ships an API that satisfies all of Fielding's constraints, and that is fine — it is not a moral failing and no interviewer worth working for will treat it as one. What the industry calls a "REST API" is really an HTTP+JSON resource API. So the useful question is never "is this really REST?" It is "how much of what HTTP already gives me is this API actually using?" That question has a well-known four-rung answer, the Richardson Maturity Model: ⚑REST maturity model. [EQ-964]⚑Richardson Maturity Model. [EQ-965]
- Level 0 — the swamp: one URL, one verb, operations named in the body (
POST /api {"action":"getUser"}). HTTP as a tunnel; SOAP's ghost. - Level 1 — resources: distinct URLs per thing (
/users/42), but verbs ignored (POST /users/42/delete). - Level 2 — verbs + status codes: resources × proper methods × meaningful statuses. This is the industry's working standard — GitHub's, Stripe's, yours. Everything in section 2–section 4 is Level-2 craft.
- Level 3 — hypermedia (HATEOAS): the response carries links telling the client what it may do next, like
"links": {"cancel": "/orders/7/cancellation"}. The client stops hardcoding URLs and starts following the ones it is handed, the same way you use a website without ever typing a URL for the second page.
Level 3 deserves an honest verdict rather than a slogan. The idea genuinely works, and you use it every day without noticing: an HTML page is pure HATEOAS, because every <a> tag is a server telling your browser where it may go next, and your browser has no built-in knowledge of the site. Where it did not win is programmatic clients written in typed languages. Those teams wanted the list of available operations at compile time, not at runtime, and they got it a different way — generated client libraries built from an OpenAPI description of the API (9.6.4). Both approaches solve "how does a client survive the API changing"; the SDK approach simply fit the tooling people already had.
The practical instruction: aim for Level 2 and finish it properly. Know Level 3 well enough to have the conversation. When an API is painful to use, the cause is almost never a missing hypermedia link — it is a Level 2 job left half done, with three endpoints that return 200 on failure and a fourth that spells the same field two different ways.
2. Resource modeling: the design act
Here is the move that decides whether an API is pleasant or miserable, and it happens before you write a single handler. You describe your system as a set of things, each with an address, and you act on those things using a verb list that never grows. The alternative — inventing a new operation name every time you need something new — is what produces the API with getUserData, getUserDataV2, fetchUserProfileFull, and loadAccountInfo all living in the same service, doing nearly the same job, none of them deletable.
http
GET /orders # list (collection resource)
POST /orders # create — server assigns identity
GET /orders/42 # read one
PATCH /orders/42 # partial update
DELETE /orders/42 # remove
GET /orders/42/items # nested collection: items OF order 42The verbs carry promises, and those promises are not decoration. Software you did not write and cannot configure — browser caches, corporate proxies, CDNs, retry libraries, crawlers — reads the verb and acts on what it means. Two words do most of the work here. A verb is safe when calling it changes nothing on the server. A verb is idempotent when calling it five times leaves the world in the same state as calling it once. Note that safe implies idempotent, but not the reverse: deleting is not safe, because it changes something, yet it is idempotent, because deleting an already-deleted thing changes nothing further.
- GET is safe. Nothing changes. Caches store the answer, browsers prefetch the link before you click it, crawlers walk it, uptime monitors poll it every thirty seconds. If you put a state change behind a GET, all four of those will eventually fire it for you, at a time nobody chose. The classic version of this incident is a team that built
GET /admin/orders/42/deletelinks for their internal tool, and then a search crawler followed every link on the page. - PUT replaces the whole thing, and is idempotent. You send the complete new representation, and the server makes the resource look exactly like that. Send it twice and the second call finds the work already done. That is what makes it safe to retry when a request times out and you have no idea whether it landed — the machinery 9.6.3 is built on.
- PATCH changes part of the thing, and is not idempotent by default. You send only the fields that change. Whether repeating it is harmless depends entirely on what you sent:
{"status": "shipped"}is fine twice,{"quantityDelta": 1}is not. - POST creates something, or does something. It is the verb with no promises attached, which is exactly why it is the one you reach for when nothing else fits. Repeating it may well charge the card twice.
- DELETE is idempotent. Gone stays gone. The only real decision is what the second delete returns:
204again, as if you had succeeded, or404, because there is nothing there now. Both are defensible. Pick one, write it in the docs, and never let a second endpoint pick the other. ⚑PUT vs PATCH. [EQ-981]
URI conventions — the checkable rules: ⚑URI naming conventions. [EQ-967]
- Plural nouns, and no verbs in the path. Write
/orders, not/getOrdersand not/order. The verb already lives in the method, so putting one in the path means you are saying it twice and will eventually contradict yourself. Plural reads correctly at both levels:/ordersis the collection,/orders/42is one member of it. - Nest only when one thing genuinely belongs to another.
/orders/42/items/3is right, because an order item has no life of its own — nobody ever wants "item 3" without knowing which order it came from. Reviews are the opposite case. A review belongs to a product, but you also want all reviews by one user, and a moderation queue of every recent review regardless of product. Things you need to reach from several directions get a top-level collection with filters:/reviews?productId=9. A useful alarm bell: if you are writing a third level of nesting, you have probably invented an ownership that does not really exist. - IDs should be opaque and permanent. Hand out UUIDs or some public identifier, never the auto-incrementing primary key from your database. Two separate reasons. First, sequential IDs let anyone walk your data — request
/orders/1,/orders/2,/orders/3and count how many orders your company has taken, or worse, read the ones your permission check forgot to cover. Second, the moment a database ID is public, moving to a different storage layout becomes a breaking change for every client. And whatever you choose, never encode meaning into the ID that clients will start parsing, because the day you change the format you break code you have never seen. - Consistency beats whichever style you personally prefer. Kebab-case in paths, camelCase in JSON fields, ISO 8601 in UTC for every timestamp. There is no correct answer among these; there is only the answer you apply everywhere. Each inconsistency is not a small aesthetic problem. It is a permanent
ifstatement in somebody else's code, because a client that must handle bothcreated_atandcreatedAtwill handle both forever.
Some operations honestly are not "create, read, update, delete" applied to a stored noun. Search. Checkout. Retry a payment. Cancel an order. Two ways of handling those are respectable.
The first, and usually the better one, is to turn the action into a thing. Instead of "cancel the order", think "create a cancellation": POST /orders/42/cancellation. This feels like ceremony until you notice that a real cancellation is not instantaneous. It has to check whether the restaurant already started cooking. It has to issue a refund, which can itself fail and be retried. Someone in support will need to ask what happened to it. All of that needs somewhere to live, and "a thing with an address and a status" is exactly the right place — the same reasoning behind treating pipeline stages as objects in 9.5.4.
The second is a plainly named action endpoint like POST /orders/42/retry, used when the action really is instantaneous and there is nothing to track. This is not a sin. Turning a trivial action into a resource nobody ever queries is its own kind of waste.
What is not acceptable is tunnelling: POST /api?action=cancelOrder, where every request goes to one URL and the real operation hides in a parameter. That is Level 0 from the ladder above, wearing a Level 2 costume. Every cache, log, metric, and access rule now sees one endpoint doing everything, so none of them can tell your read traffic from your money-moving traffic.
3. Status codes: the response's type system
In 3.7.3 you met the tagged union: a value that can be one of several shapes, carrying a small field that says which shape it is, so the code reading it knows which branch to take. The status code is that field for an HTTP response. Everything downstream — your client code, the retry library, the dashboard counting failures, the pager that wakes someone at 3am — reads the number first and decides what this response is before looking at the body. Send the wrong number and you have not been imprecise. You have lied about the type, and every layer that trusted you acts on the lie.
Here is the working set. The right-hand column is the part people get wrong, because it names the neighbouring code you might have reached for instead:
| Code | Means — precisely | The distinction that matters |
|---|---|---|
200 | success, body enclosed | vs 201: nothing was created |
201 | created; Location header points at it | POST/PUT that made a resource |
202 | accepted, processing later | the async-work contract (+ status URL) |
204 | success, deliberately no body | DELETE, and updates that return nothing |
400 | request malformed/invalid | client must change the request |
401 | not authenticated (who are you?) | vs 403: credentials absent/invalid |
403 | authenticated but not allowed | identity known; permission denied |
404 | no such resource | also the "you can't know it exists" privacy code |
409 | conflict with current state | duplicate create, stale-state writes (9.6.3) |
422 | well-formed but semantically invalid | validation failures (vs 400 syntax) — pick a house rule |
429 | rate limited | + Retry-After (9.5.4's reject policy) |
500 | our bug | client retrying won't help until we fix it |
503 | temporarily can't (overload/maintenance) | retryable; + Retry-After |
Three rules prevent most of the damage.
Never return 200 with an error inside it. The shape 200 {"success": false} looks harmless and is one of the most expensive habits in the industry, because it puts the tag in the body where nothing generic can see it. Section "What the interviewer will push on" below walks through exactly which five systems this breaks and why.
4xx means the request was wrong, 5xx means we were wrong. This line is not philosophy, it is routing. Retry logic uses it: retrying a 400 will fail identically forever, retrying a 503 will probably work. Dashboards use it to tell "users are sending us junk" from "we are broken". On-call alerting uses it to decide whether to wake somebody. So when a validation failure gets logged as a 500, the cost is not a wrong number on a chart. It is an engineer woken at 3am to discover a customer typed a bad email address.
Pick a side on the two blurry lines and never move. The 401-versus-403 line and the 400-versus-422 line both have defensible answers in either direction. What has no defensible version is an API where three endpoints answer 400 for a validation failure and two answer 422, because now every client needs to handle both, and the client author has to guess which endpoints belong to which era of your codebase.
4. Error design: errors are part of the API
People write code against your errors. That sentence is worth sitting with, because most teams design the success case carefully and let errors happen. But a frontend has to show the right message, retry the right failures, and paint the right input box red — all decided by branching on what you sent back. If all you sent is "something went wrong", the only thing left to branch on is the text itself, so somebody writes if (msg.includes("insufficient funds")). That code breaks the day a copywriter improves the wording, and it breaks silently, in production, with no compile error anywhere.
The standard shape for doing this properly is RFC 9457 (Problem Details, previously numbered 7807). You can use your own format instead; what you cannot skip is the anatomy:
jsonc
// 422 Unprocessable Entity
{
"type": "https://api.example.com/errors/validation", // stable error CLASS (URI/slug)
"title": "Validation failed", // human summary, stable-ish
"status": 422,
"code": "VALIDATION_FAILED", // machine branch target
"detail": "2 fields failed validation",
"errors": [ // per-field specifics —
{ "field": "email", "code": "INVALID_FORMAT", "message": "not an email" },
{ "field": "age", "code": "OUT_OF_RANGE", "message": "must be ≥ 18" }
],
"traceId": "b7ad6b7169203331" // support/debugging bridge (3.8.7)
}Five rules govern that envelope.
The code field is permanent API surface; the message is not. Split your error into two audiences. message and title are for a human reading a screen or a log, and you should feel free to improve their wording whenever it helps. code is for a machine deciding what to do, so once you ship VALIDATION_FAILED you have promised it forever. State this split in your docs explicitly, because clients that were never told will branch on whatever looks stable.
Validation errors need to be per-field. One error object for the whole request means the frontend can only show one generic banner saying the form was rejected, and the user has to hunt for which of eleven fields was the problem. An errors[] array with a field on each entry means the form highlights the two bad inputs and puts the reason under each one. This is the difference between a form people can complete and the form everyone abandons.
Every error carries a trace ID. A user writes in saying "it didn't work yesterday". Without a trace ID that ticket is unanswerable, and someone spends an hour grepping logs by guessing at timestamps. With one, you paste the string into your log search and see the exact request. This is the request-scoped ID from 3.8.7, finally surfacing where users can copy it.
Never leak internals. No stack traces, no SQL text, no internal hostnames or file paths. These feel like helpfulness and are actually a free reconnaissance report: a leaked SQL fragment tells an attacker your table names and your query builder, and a stack trace tells them your framework versions (Part 8.5).
One envelope for every error the API can produce. This is the rule teams skip, because it is not about any single handler. A client should be able to write one function that parses an error and use it for all of them: the 404 that the router produced before your code ran, the 422 your validation layer produced, and the 500 that came from an unhandled exception deep in a database driver. Those three are generated in completely different places by completely different code, which is exactly why they end up with three different shapes unless someone deliberately funnels them together. 9.9 shows the wiring — a single Express error middleware that everything lands in.
5. The expert lens
Model your resources on the business, not on your tables. The fastest way to build an API is to generate one endpoint per database table, and it is a trap. Your API is a view of the business that outsiders are allowed to see; your schema is an implementation detail that exists to make queries fast. They should be free to differ. /orders/42 might be stitched together from five tables. /carts/current/checkout might not correspond to any table at all. This is 9.3.9's dependency inversion applied at the edge of the whole system: clients depend on the shape you promised, not on the shape you happen to store.
Two things go wrong when the API mirrors the tables. Every schema migration becomes a breaking API change, so the database you were free to reshape yesterday is now frozen by forty clients. And clients start receiving raw rows they have to assemble meaning from, which means each of them reimplements your business logic, slightly differently, in a language you do not control. That is the anemic model of 9.2.2 — data with the behaviour stripped off — except now it is spread across other companies. There is a one-line test for this: could you split that table in two next quarter without changing a single URL? ⚑REST constraints and resource modeling. [EQ-985]
Statelessness is a price you pay on purpose, and you should know when you are paying it. Someone will ask "can't we just keep it in memory on the server?" — for a session, for a half-finished multi-step form, for an upload in progress. The answer is yes, and the cost is always identical: you have just given up the property that any server can answer any request. Now that user must come back to this machine, which means a deploy that restarts it loses their work, and the load balancer must be told to keep sending them to the same place.
That single trade sits underneath three decisions you have already met or will meet: server-side sessions versus JWTs (Part 8.4.2), sticky sessions and the warning about clustered processes in 3.8.6, and resumable uploads in Chapter 11.2. Sometimes paying is correct. The engineering act is saying the sentence out loud: "we are giving up serve-from-anywhere in exchange for X", so the room agrees on what X is worth.
Design for the person reading your docs, not the person writing your handler. Quality here is measured entirely at the far end. Can someone guess /restaurants/{id}/menu after seeing /orders/{id}/items? Is there one date format, one error shape, one naming style? Does GET really never change anything, so the retry logic can trust it? Optimising for the handler author is how you get /getUserData2, because the second version was easier to add than to reconcile with the first. Optimising for the reader is how you get an API people call "clean" without being able to point at why. The full checklist — literally the list a frontend engineer should hand a backend team — closes this folder in 9.6.4.
What the interviewer will push on
API design shows up in almost every backend interview, usually disguised as a system-design question. The follow-ups are predictable.
"Should this be PUT or PATCH?" They are checking whether you reason from idempotency or from habit. The good answer names what happens on a retry: PUT sends the whole object so a duplicate is harmless, PATCH sends a change so a duplicate may apply it twice. The tell of someone who has shipped this is that they mention a network timeout, where the client genuinely cannot tell whether the first request landed. The weak answer is "PATCH is for small updates" — true, and it explains nothing about why it matters.
"Why not just return 200 with an error object?" They want to know if you understand that HTTP has consumers other than your own code. Name specific victims: a shared cache may store your error page as if it were the real resource; your monitoring counts the outage as success, so the graph stays flat during an incident; retry middleware sees success and does not retry; a generated client SDK resolves the promise instead of rejecting, pushing error checks into every call site; and load-balancer health checks keep the broken instance in rotation. Five systems, one habit. The wrong answer is "it's not RESTful", which is a rule quoted rather than a reason understood.
"401 or 403 here?" They are testing precision on a distinction people fudge daily. 401 means we do not know who you are — the token is missing, expired, or malformed, so getting a valid one might help. 403 means we know exactly who you are and you still may not do this, so retrying with a fresh token changes nothing. The subtlety worth raising: sometimes the right answer is 404 even when the resource exists, because telling a stranger "that account exists but you can't see it" is itself a leak. The weak answer treats 403 as "any auth problem".
"Someone else's team wants an endpoint that does five things at once. What do you do?" This is a design-judgement question wearing a REST costume. They are checking whether you can distinguish a genuine need from a shortcut. The strong move is to ask what the client screen needs, because a request for one fat endpoint usually means the caller is making five round trips and one of them is slow. That might be solved by a resource that genuinely represents the composite thing, or by field selection (9.6.2), and only rarely by an endpoint named after a workflow.
"How would you change an API that already has customers?" The answer they are listening for is additively. New optional fields are safe; new required fields, removed fields, renamed fields, and narrowed types are not. Anything genuinely breaking goes in a new version alongside the old one, with a stated end date (9.6.3). The wrong answer is any sentence containing "we'll just tell them to update".
Volunteer this one, because nobody asks: say that you would put the error envelope in place on day one, before any endpoint exists, and that every error — router 404s, validation failures, unhandled crashes — must exit through the same function. Almost everyone designs the happy path and lets errors evolve on their own, so an API ends up with four error shapes and no way to remove three of them. Saying this signals you have maintained an API rather than started one.
Next: 9.6.2 — what happens when a collection stops being small: offset, cursor and keyset pagination, filtering, sorting, and letting clients ask for fewer fields.
Recall
- REST = Fielding's constraints (client–server, stateless, cacheable, uniform interface, layered); industry reality = HTTP+JSON resource APIs graded by the Richardson Maturity Model: L0 tunnel → L1 resources → L2 verbs+codes (the working standard) → L3 HATEOAS (idea lives in hypermedia + generated SDKs won instead). Most pain = incomplete L2.
- Modeling: plural nouns, verbs from the method; hierarchy only for true ownership (deep nesting = faked ownership); opaque stable IDs (never DB keys); consistency linted. Verb contract: GET safe, PUT whole+idempotent, PATCH partial, DELETE idempotent, POST creates/does. Non-CRUD actions: reify as resources (
POST /orders/42/cancellation— actions have lifecycles) or action sub-resources. - Status codes = the response's type tag: 201-vs-200, 202 async contract, 204 no-body, 401 who-vs-403 may, 409 state conflict, 422 semantic vs 400 syntax, 429/503 +
Retry-After. Never 200-with-error; 4xx/5xx = whose fault (routes retries, monitoring, on-call). - Errors are API surface: Problem-Details-shaped envelope — stable machine
code(never branch on messages), field-level validation errors,traceIdin every error, zero internals leaked, one envelope for all errors. - Lens: model domain nouns not tables (re-shard test); statelessness = the scaling trade, always named; design for the docs reader.
Self-test: Grade an API you know on the Richardson ladder and name what completing L2 would take. When does nesting a resource lie? Recite the GET/PUT/PATCH/DELETE contract. Why is 200-with-error-body a type error? What five properties must the error envelope have?
Quiz Bank
FoundationalWhat is the Richardson Maturity Model, and why is Level 2 the working standard rather than Level 3?
A four-rung ladder grading how much of HTTP's uniform interface an API exploits. L0: one URL, one verb, operation named in the body — HTTP as a transport tunnel. L1: distinct URLs per resource, verbs still ignored (POST /users/42/delete). L2: resources × correct methods × meaningful status codes — verb semantics (safe GET, idempotent PUT/DELETE) and code semantics become a real contract that caches, proxies, retries, and monitors can rely on. L3 (HATEOAS): responses embed links naming available next actions, so clients navigate capabilities instead of hardcoding URLs. L2 won in practice because its benefits are consumed by infrastructure automatically (caching, retry safety, monitoring), while L3's benefit — runtime evolvability — assumed clients that generically follow links; real typed clients wanted compile-time contracts instead, and OpenAPI-generated SDKs (9.6.4) delivered evolvability that way. The idea survives where clients genuinely are generic: browsers rendering HTML (every <a> is HATEOAS). Interview framing: target complete L2; discuss L3 knowledgeably; diagnose most "REST pain" as incomplete L2.
FoundationalRecite the verb contract — safety and idempotency per method — and one infrastructure behavior that depends on each guarantee.
GET — safe (no state change) and idempotent: caches ([5.6]) store it, browsers prefetch it, crawlers hit it, monitors poll it — a state-mutating GET will be triggered by all four (the classic "crawler deleted our data" incident class). PUT — idempotent, full replacement: retry logic (client or proxy) may safely re-send on timeout without double effects; also why PUT suits "create with client-chosen ID." PATCH — partial update, not idempotent by default (an increment patch applied twice ≠ once): retries need 9.6.3's idempotency keys or conditional requests. DELETE — idempotent (absent stays absent): retries safe; the 204-vs-404-on-second-delete choice is yours but must be documented and consistent. POST — neither safe nor idempotent: the workhorse for create/act; browsers warn on re-submit, retries are dangerous — which is exactly why payment-style POSTs carry idempotency keys (9.6.3). The meta-point: these aren't conventions but essential promises that HTTP infrastructure — caches, retry middleware, prefetchers — acts on without asking you.
AppliedDesign the URI + verb surface for: a user's saved addresses, product reviews (browsable independently and per-product), and 'retry a failed payment'. Justify each choice.
Addresses — true ownership (meaningless outside their user): nest one level — GET/POST /users/{id}/addresses, GET/PATCH/DELETE /users/{id}/addresses/{addrId} (or /me/addresses for the authenticated user — both, with /me as the primary documented form).
Reviews — independently addressable (product pages need per-product lists; moderation and user-profile pages need cross-product access): top-level collection with filters — GET /reviews?productId=9, GET /reviews?userId=42, POST /reviews (product ID in the body), GET /reviews/{id} — nesting under /products/{id}/reviews as well is acceptable sugar for the read path, but the standard addressable form is top-level; nesting-only would make moderation tooling ugly and fake an ownership that isn't there.
Retry payment — a non-CRUD action with a lifecycle: reify — POST /payments/{id}/retries creating a retry attempt (201 + Location: /payments/{id}/retries/3), each attempt queryable with its own status — because retries genuinely have state (pending/succeeded/failed), audit value, and idempotency needs (9.5.4's pipeline thinking applied to the API surface); POST /payments/{id}/retry returning 202 is the acceptable lighter spelling if attempts don't need addressing. Cross-cutting: plural nouns, opaque IDs, and the same casing/date conventions across all three.
InterviewWhy is 200-with-an-error-body an anti-pattern, and what does a correct error response contain?
Because the status code is the response's type tag — the field every generic layer branches on without reading bodies: HTTP caches may store a 200 (your error page is now cached as the resource); monitors count 200s as successes (outage invisible in dashboards); retry middleware won't retry what looks successful; client SDKs resolve instead of reject, pushing error detection into every call site's body-sniffing; and load balancer health checks pass while the API fails. 200 {"success": false} breaks all five simultaneously — it's lying to the type system of the web. Correct shape: the right 4xx/5xx tag plus a Problem-Details-style envelope — stable machine-readable code (the branch target; messages are prose that may change), human title/detail, per-field errors[] for validation (the frontend paints specific inputs), a traceId bridging users to logs (3.8.7), and no internals (stacks, SQL, hosts — Part 8.5). One envelope for every error source — router 404s, validation 422s, crash 500s — parseable identically, which is what 9.9's centralized error middleware exists to guarantee.
StaffA partner-facing API you inherit mirrors its MySQL schema: /tbl_user_acct/{numeric_id}, verbs via ?action=, errors as 200 + free-text strings, and 40 external integrators depend on today's shapes. Design the modernization without breaking anyone, and name the deeper lesson for the org.
Constraints first: 40 integrators = today's surface is frozen (3.6.1's permanence law — you may only add). The plan: (1) New surface beside the old — stand up /v2 (9.6.3's versioning applies) designed by this page: domain-noun resources (not tbl_user_acct — the mapping layer from domain nouns to legacy tables lives server-side, exactly the anti-corruption adapter of 9.4.7), real verbs and codes, Problem-Details errors with stable codes, opaque public IDs minted alongside numeric ones (a mapping table — the enumeration-attack hole closes for v2 clients). (2)
One implementation, two faces — v1 handlers become thin adapters over the same domain services v2 uses (strangler-fig shape, Part 10.11): no dual business logic, and v1's behavior is characterized by contract tests before refactoring beneath it ([9.8]-discipline: pin, then move). (3)
Migration mechanics — deprecation headers on v1 responses (Deprecation, Sunset, Link to the migration guide), per-integrator usage telemetry so outreach is targeted, a long honest overlap window with committed dates, and v2-only carrots (webhooks, pagination, SDKs from the OpenAPI spec — 9.6.4) so movement is pulled, not only pushed; v1 finally freezes into maintenance (security-only) rather than deletion if stragglers pay to justify it.
The org lesson to institutionalize: the schema-mirroring API happened because the interface was treated as a byproduct of the implementation; the correction is process, not heroics — API review before shipping (the 9.6.4 checklist as the gate), spec-first design for anything partner-facing, and the ownership question asked at every new endpoint:
if we re-shard the database next year, does this URL survive? An API is a promise made in public; the modernization's real deliverable is an org that stops making accidental promises.
Flashcards
FlashRichardson ladder
L0 tunnel → L1 resources → L2 verbs+codes (the working standard — do it completely) → L3 HATEOAS (hypermedia; SDKs won for typed clients).
FlashVerb contract
GET safe · PUT whole+idempotent · PATCH partial, not idempotent by default · DELETE idempotent · POST the non-idempotent workhorse. Infrastructure acts on these promises.
FlashURI rules
Plural nouns, no verbs; nest only true ownership (≤2 levels); opaque stable IDs; consistency linted. Actions → reified resources (/cancellation).
FlashStatus code lines
201 created (+Location) · 202 async · 204 no body · 401 who? vs 403 may? · 409 state conflict · 422 semantic vs 400 syntax · 429/503 + Retry-After. Never 200-with-error.
FlashError envelope
Right status + stable machine code + field-level errors[] + traceId + zero internals — one envelope for every error source.
FlashTwo lens tests
Re-shard test: URLs survive a DB reshape? Docs-reader test: guessable, consistent, honest verbs/codes.
Scenario Drill
DrillDesign the complete resource surface for a food-delivery API's ordering flow: browse restaurant menus, build a cart, place the order, track it live, cancel within a window, and rate afterward — including verbs, codes, the async and action cases, and the error contract. Then defend two deliberately non-obvious choices.
Surface, walked in flow order. Menus: GET /restaurants (filters: ?near=, ?cuisine= — 9.6.2's grammar), GET /restaurants/{id}, GET /restaurants/{id}/menu — true ownership nesting (a menu is meaningless without its restaurant); menus are cache-friendly GETs (Cache-Control, ETags — 9.6.3). Cart: GET /carts/current, PUT /carts/current/items/{itemId} (set quantity — PUT because "set to N" is idempotent; retrying a flaky tap must not double the biryani), DELETE /carts/current/items/{itemId}; the cart is per-user server state addressed statelessly (current resolves from auth — every request self-contained, section 1's constraint honored while still having a cart). Place order: POST /orders with the cart snapshot and an idempotency key header (9.6.3 — payment-adjacent POSTs always) → 201 + Location: /orders/{id}, or 202 if placement involves async restaurant confirmation — then the body carries status: "pending_confirmation" and the client polls the resource (the 202 contract: accepted ≠ confirmed).
Track: GET /orders/{id} returns the 9.5.4 pipeline's status field (placed → confirmed → preparing → picked_up → delivered) — the API's status enum is the state machine's, one vocabulary end to end; live push upgrades (SSE/WebSocket) are a [5.8]-decision, with polling this resource as the universal fallback. Cancel: POST /orders/{id}/cancellation — the reified action (section 2): it can be rejected by state (409 when the pipeline has passed the cancellable window — the state machine's illegal-transition surfacing as the right status code, not a 400), can carry a refund status of its own (GET /orders/{id}/cancellation), and is idempotent-keyed. Rate: POST /orders/{id}/rating (201; 409 on second attempt — one rating per order is a state conflict, not validation).
Error contract throughout: one Problem-Details envelope; 422 with field errors for bad requests (items[2].quantity OUT_OF_RANGE), 409 with machine codes for state conflicts (ORDER_NOT_CANCELLABLE + detail naming the current status — the client renders "your order is already being prepared"), traceId everywhere.
Two defenses: (1) Why cancellation is a resource, not DELETE /orders/{id} — the order isn't ceasing to exist (it remains visible, auditable, refundable); cancellation is a business action with its own lifecycle and failure modes (refund pending/failed), and DELETE's idempotent-removal semantics would lie about all of it. (2)
Why cart item set-quantity is PUT, not PATCH-increment — mobile networks retry; "set to 3" survives duplicates, "+1" doesn't (9.6.3's idempotency-by-design: choose operations that are naturally idempotent before reaching for keys). The drill's summary sentence: the API surface is the state machine, the ownership graph, and the guarantee classes of 9.5.4 — rendered in nouns, verbs, and status codes.