Skip to content

Volume ITechnology

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 42

The 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/delete links 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: 204 again, as if you had succeeded, or 404, 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 /getOrders and 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: /orders is the collection, /orders/42 is one member of it.
  • Nest only when one thing genuinely belongs to another. /orders/42/items/3 is 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/3 and 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 if statement in somebody else's code, because a client that must handle both created_at and createdAt will 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:

CodeMeans — preciselyThe distinction that matters
200success, body enclosedvs 201: nothing was created
201created; Location header points at itPOST/PUT that made a resource
202accepted, processing laterthe async-work contract (+ status URL)
204success, deliberately no bodyDELETE, and updates that return nothing
400request malformed/invalidclient must change the request
401not authenticated (who are you?)vs 403: credentials absent/invalid
403authenticated but not allowedidentity known; permission denied
404no such resourcealso the "you can't know it exists" privacy code
409conflict with current stateduplicate create, stale-state writes (9.6.3)
422well-formed but semantically invalidvalidation failures (vs 400 syntax) — pick a house rule
429rate limited+ Retry-After (9.5.4's reject policy)
500our bugclient retrying won't help until we fix it
503temporarily 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 the 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, so it does not retry.
  • A generated client SDK resolves the promise instead of rejecting it, so every caller has to check for errors by hand.
  • Load-balancer health checks keep the broken server in use.

Five systems, all fooled by 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, traceId in 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 is safe (it changes nothing) and idempotent. Caches store it (5.6), browsers fetch it early, search crawlers visit it, and monitors poll it. A GET that changes data will be triggered by all four. That is the classic "a crawler deleted our data" incident.
  • PUT is idempotent and replaces the whole thing. Retry logic, in the client or a proxy, may safely send it again after a timeout, with no double effect. That is also why PUT suits "create with an ID the client chose".
  • PATCH changes part of something, and is not idempotent by default. A patch that adds one, applied twice, is not the same as applied once. Retries need idempotency keys or conditional requests (9.6.3).
  • DELETE is idempotent. Something that is gone stays gone, so retries are safe. Whether a second delete returns 204 or 404 is your choice, but document it and stay consistent.
  • POST is neither safe nor idempotent. It is the workhorse for creating things and for actions. Browsers warn before submitting it again, and retries are dangerous. That is exactly why payment-style POSTs carry idempotency keys (9.6.3).

The bigger point: these are not style conventions. They are promises that the web's infrastructure, such as caches, retry middleware and 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: give them their own top-level address. Product pages need the reviews for one product. But moderation pages and user-profile pages need reviews across all products. So make reviews a top-level collection with filters:

  • GET /reviews?productId=9
  • GET /reviews?userId=42
  • POST /reviews, with the product ID in the body
  • GET /reviews/{id}

You may also offer /products/{id}/reviews as a convenient shortcut for reading. But the main address is the top-level one. If reviews lived only under products, moderation tools would be awkward, and the URL would claim that a product owns its reviews when it does not.

Retry a payment: an action that has a life of its own. Turn the action into a thing. POST /payments/{id}/retries creates a retry attempt and returns 201 with Location: /payments/{id}/retries/3. Each attempt can then be looked up, with its own status. This is worth it because a retry really does have state: pending, succeeded or failed. It is also worth keeping for audits, and it must be safe to repeat (9.5.4). If nobody needs to look up attempts later, POST /payments/{id}/retry returning 202 is an acceptable lighter version.

Across all three: use plural nouns, IDs that reveal nothing, and the same casing and date format everywhere.

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 label. Every general-purpose layer decides what to do from that label alone, without reading the body:

  • HTTP caches may store a 200, so your error page gets cached as if it were the real thing.
  • Monitors count 200s as successes, so the outage never shows on the dashboard.
  • Retry middleware does not retry something that looks successful.
  • Client SDKs resolve instead of reject, so every caller has to dig through the body to find errors.
  • Load-balancer health checks pass while the API is failing.

200 {"success": false} breaks all five at once. It lies to every piece of the web that trusts the label.

The correct shape is the right 4xx or 5xx code plus a Problem Details style error body. That body has:

  • A stable code that programs can branch on. The message is human text and may change, so programs must not rely on it.
  • A human-readable title and detail.
  • An errors[] list for validation, one entry per field, so the frontend can highlight the exact inputs.
  • A traceId that links what the user sees to the logs (3.8.7).
  • No internal details: no stack traces, SQL or host names (Part 8.5).

Every kind of error uses this one body, whether it is a router 404, a validation 422 or a crash 500. So clients parse all of them the same way. Guaranteeing that is the whole job of the central error middleware in 9.9.

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.

Start with the limits. With 40 companies already calling the API, what exists today is frozen. You may add to it, but never change it (3.6.1).

The plan:

(1) Build the new API next to the old one. Stand up /v2, versioned as in 9.6.3, and design it the way this page teaches:

  • Resources named after business things, not database tables like tbl_user_acct. The server translates between the two. That translation layer is the anti-corruption adapter from 9.4.7.
  • Real HTTP verbs and real status codes.
  • Problem Details errors with stable codes.
  • Public IDs that reveal nothing, created alongside the old numeric IDs and linked by a mapping table. That stops v2 callers from guessing other people's IDs by counting.

(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)

How the move happens:

  • Every v1 response carries headers announcing the end: Deprecation, Sunset, and a Link to the migration guide.
  • Track usage per calling company, so you contact the right people.
  • Keep both versions running side by side for a long time, with dates you promise and keep.
  • Offer things only v2 has, such as webhooks, pagination and SDKs generated from the OpenAPI spec (9.6.4). People should want to move, not only be pushed.
  • At the end, v1 is frozen and gets only security fixes. It is deleted only if no paying customer still needs it.

The lesson for the whole team: the API ended up copying the database because the interface was treated as a side effect of the code. The fix is a process, not heroics:

  • Review every API before it ships, with the 9.6.4 checklist as the gate.
  • Write the spec first for anything partners will use.
  • Ask the ownership question for 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.

The endpoints, in the order a customer uses them.

Menus.

  • GET /restaurants, with filters such as ?near= and ?cuisine=, using the grammar of 9.6.2.
  • GET /restaurants/{id}.
  • GET /restaurants/{id}/menu. Nesting is right here, because a menu means nothing without its restaurant. Menus are GETs that cache well, with Cache-Control and ETags (9.6.3).

Cart.

  • GET /carts/current.
  • PUT /carts/current/items/{itemId} sets the quantity. It is a PUT because "set it to N" is idempotent. Retrying a shaky tap must not give you two biryanis.
  • DELETE /carts/current/items/{itemId}.
  • The cart is stored on the server per user, but each request still stands on its own: current is worked out from the login token. So the stateless rule from section 1 holds, and you still get a cart.

Place the order.

  • POST /orders, with a snapshot of the cart and an idempotency key header. Any POST near payment always has one (9.6.3).
  • It returns 201 with Location: /orders/{id}. Or, if the restaurant confirms later, it returns 202 with status: "pending_confirmation" in the body, and the app polls the order. That is the 202 promise: accepted does not mean confirmed.

Track. GET /orders/{id} returns the status from the 9.5.4 pipeline: placed, confirmed, preparing, picked up, delivered. The API's list of statuses is the state machine's list, so the same words are used everywhere. Pushing live updates with SSE or WebSocket is a separate decision (5.8). Polling this endpoint always works as a fallback.

Cancel. POST /orders/{id}/cancellation. The action becomes a thing, as in section 2. It can be refused because of the order's state: 409 once the order is past the point where it can be cancelled. That is the state machine refusing a move, shown with the right status code, not a 400. It can have a refund status of its own, at GET /orders/{id}/cancellation. And it carries an idempotency key.

Rate. POST /orders/{id}/rating returns 201, and 409 on a second try. One rating per order is a conflict with the current state, not a validation error.

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 cancelling is a resource, and not DELETE /orders/{id}. The order does not stop existing. It stays visible, it stays in the records, and it can still be refunded. Cancelling is a business action with a life of its own, and it can fail, for example when a refund is pending or fails. DELETE means "remove this, and removing it twice is the same as once". That would be a 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.