Lesson 1 of 6
REST & Resource Design
"A good API is like a good joke: if you have to explain it, it isn't that good." — a maxim passed around API design reviews
You have met HTTP — its methods, status codes, and headers — and you have seen that a backend is, at heart, a function from request to response. Now we design the shape of that contract: the URLs your clients call, the verbs they use, the data they get back. This is API design, and REST is the style that has quietly organized the majority of the web's APIs for two decades.
Get this right and your API feels obvious: a frontend engineer guesses the endpoint before reading the docs, a mobile team integrates in an afternoon, and a new hire ships a feature without a meeting. Get it wrong and every screen needs a Slack thread, every field has three casings, and nobody is sure whether POST /deleteUser really deletes. The difference is not cleverness — it is a handful of conventions applied with discipline. By the end of this chapter you will design resource-oriented endpoints, map operations to methods and status codes correctly, and defend your choices in an interview.
Given a "thing" your product manages — a user, an order, a playlist — how do you turn it into a set of URLs and HTTP methods that any client can understand at a glance, without you explaining each one?
REST is a style, not a spec
REST — REpresentational State Transfer — was described by Roy Fielding in his 2000 PhD dissertation, not as a protocol you install but as an architectural style: a set of constraints that, if you honor them, tend to produce systems that are scalable, evolvable, and simple to reason about. It is deliberately built on top of HTTP's existing machinery rather than inventing new machinery.
The single idea at REST's core is this: model your system as resources — the nouns of your domain (a user, an order, a comment) — each identified by a URL, and manipulate them with HTTP's small, fixed set of methods — the verbs (GET, POST, PUT, PATCH, DELETE). You do not invent an operation vocabulary; you reuse HTTP's. GET /users/42 means "read user 42." DELETE /users/42 means "remove user 42." The method says what you're doing; the URL says what you're doing it to.
Analogy Think of a library (the building, not this one). Every book has a fixed shelf address — that's its URL, its stable identity. The things you can do are a small, universal set printed on the wall: borrow, return, read-in-place, remove. You never invent a new verb per book ("de-shelf-the-red-ones"); you apply the same handful of actions to any address. REST is that discipline: stable noun-addresses, a fixed set of verbs.
Where REST came from By the late 1990s, machines talked to each other with heavyweight RPC protocols (SOAP, XML-RPC) that tunneled function calls over HTTP, ignoring HTTP's own semantics — everything was a POST to a single endpoint. Fielding, who co-authored the HTTP spec itself, argued the web already had a beautifully general interface; we were just failing to use it. REST was the correction: stop tunneling RPC, start using HTTP as designed. The industry took roughly a decade to come around, but by the 2010s "REST API" was the default answer.
Naming resources: nouns, plural, hierarchical
The URL is the identity of a resource, and the conventions for naming are near-universal. Follow them and your API will feel familiar to every engineer who has touched a REST API before.
| Rule | Good | Bad |
|---|---|---|
| Use nouns, not verbs | /users |
/getUsers, /createUser |
| Plural collections | /users, /orders |
/user, /order |
| A single item is the collection plus an id | /users/42 |
/user?id=42 |
| Nest to show ownership | /users/42/orders |
/orders?belongsToUser=42 (ok, but less clear for owned sub-resources) |
| Lower-case, hyphenated multi-word paths | /blog-posts |
/blogPosts, /Blog_Posts |
| No file extensions or actions in the path | /reports/2026 |
/reports/2026.json, /reports/generate |
The verb lives in the HTTP method, never in the path. The moment you see POST /users/42/delete or GET /fetchOrders, you are looking at RPC wearing a REST costume. The path names a thing; the method names the action.
Nesting expresses containment. /users/42/orders reads as "the orders belonging to user 42" and is the natural URL for listing or creating within that scope. A useful discipline: nest only one level deep. /users/42/orders/1001/items/3 is technically legal but painful; once an order has its own identity, prefer /orders/1001 and let it carry a userId field. Deep nesting couples URLs to a hierarchy that often changes.
Technique — the "can I guess it?" test A well-designed REST API is predictable. If a client knows GET /users lists users and GET /users/42 reads one, they should be able to guess that GET /users/42/orders lists that user's orders, POST /users creates a user, and DELETE /users/42 removes one — without reading a single line of documentation. When you finish an endpoint, ask: "Could a competent engineer have guessed this?" If not, your naming is fighting the conventions.
Mapping CRUD to methods and status codes
Most resource operations reduce to CRUD — Create, Read, Update, Delete — and each maps to a method, a URL, and an expected success status code. This table is the backbone of REST API design; internalize it.
| Operation | Method | URL | Success status | Notes |
|---|---|---|---|---|
| List collection | GET |
/users |
200 OK |
Returns an array (often paginated) |
| Read one | GET |
/users/42 |
200 OK |
404 if it doesn't exist |
| Create | POST |
/users |
201 Created |
Return the new resource + Location header |
| Replace (full update) | PUT |
/users/42 |
200 OK |
Send the entire resource |
| Partial update | PATCH |
/users/42 |
200 OK |
Send only changed fields |
| Delete | DELETE |
/users/42 |
204 No Content |
Empty body; 404 if absent |
A few status codes carry real meaning and are worth getting exactly right:
201 Createdon a successfulPOST, ideally with aLocation: /users/43header pointing at the new resource.204 No Contenton a successfulDELETE(or aPUT/PATCHthat returns nothing) — the request worked, there is simply no body to send.400 Bad Requestfor malformed or invalid input;422 Unprocessable Entitywhen the syntax is fine but the semantics fail validation (many teams use one or the other consistently).401 Unauthorizedmeans "not authenticated" (we don't know who you are);403 Forbiddenmeans "authenticated but not allowed."404 Not Foundfor a resource that doesn't exist;409 Conflictfor a create that collides (duplicate email) or a version mismatch.
In production The single most common REST sin is returning 200 OK with an { "error": ... } body. It breaks everything downstream: HTTP caches cache the error, monitoring dashboards show 100% success while users scream, retry logic never fires, and client code has to parse the body to discover it failed. The status code is the first and most machine-readable signal of what happened. Spend it honestly — a failure is a 4xx or 5xx, always.
PUT vs PATCH vs POST — the classic confusion
These three are asked in interviews constantly because they are genuinely easy to conflate:
POSTcreates a new resource in a collection, or triggers a non-idempotent action. The server usually assigns the id. Calling it twice creates two resources.PUTreplaces a resource at a known URL with the full representation you send. It is idempotent:PUT /users/42with the same body ten times leaves the same final state as doing it once. Fields you omit are conceptually cleared.PATCHpartially updates — you send only the fields that change (e.g.{ "email": "new@x.io" }) and leave the rest untouched.
We'll return to idempotency properly in Part VI (it's the key to safe retries), but note the shape now: GET, PUT, DELETE are idempotent — repeating them lands you in the same state — while POST and PATCH generally are not.
Modeling resources in code
Because a resource is really just a well-defined shape plus a set of operations, we can model the types and the routing table in runnable TypeScript. The block below defines the resource type, separate DTOs for create/update (note: no client-supplied id), and a tiny router that maps METHOD path to a handler name — the exact mental model a framework automates for you. It runs through the real TS compiler and prints the resolved routes.
Notice what fell out for free: the create DTO has no id (the server owns identity), the update DTO is all-optional (that is PATCH semantics in the type system), and a POST to an item URL resolves to nothing — which a real framework would turn into a 405 Method Not Allowed. The types encode the design.
Here is what the same design looks like on the wire — show-only, because real HTTP doesn't run in the sandbox:
POST /orders HTTP/1.1
Content-Type: application/json
{ "userId": 42, "total": 99.5 }
HTTP/1.1 201 Created
Location: /orders/1001
Content-Type: application/json
{ "id": 1001, "userId": 42, "total": 99.5, "status": "pending", "createdAt": "2026-07-28T10:04:00Z" }
PATCH /orders/1001 HTTP/1.1
Content-Type: application/json
{ "status": "paid" }
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 1001, "userId": 42, "total": 99.5, "status": "paid", "createdAt": "2026-07-28T10:04:00Z" }
Representations: collections, single resources, and JSON
A resource and its representation are not the same thing. The order lives in your database as rows; what crosses the wire is a representation of it — today, almost always JSON. The same resource might have multiple representations (JSON, CSV, a summary vs. a full view), negotiated via the Accept header, though most APIs standardize on JSON and move on.
Two shapes recur:
- A single resource (
GET /orders/1001) returns one JSON object. - A collection (
GET /orders) returns a list. Wrap it in an envelope so you have room for metadata — total counts, pagination cursors, links — rather than returning a bare top-level array:
GET /orders?status=paid&sort=-createdAt&page=2 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [ { "id": 1002, "status": "paid" }, { "id": 1003, "status": "paid" } ],
"page": 2,
"pageSize": 20,
"total": 57
}
Filtering, sorting, and pagination live in the query string
The path identifies which resources; the query string refines how you want the collection sliced. This is the right home for anything that isn't identity:
- Filter:
GET /orders?status=paid&userId=42 - Sort:
GET /orders?sort=-createdAt(a leading-for descending is a common convention) - Paginate:
GET /orders?page=2&pageSize=20, or cursor-style?limit=20&after=eyJpZCI6MTAwMn0
Never return an unbounded collection. A GET /orders on a table with ten million rows must not try to serialize ten million objects — it will exhaust memory and time out. Default to a sane page size and cap the maximum. We design pagination properly in Part VI — cursor vs. offset, stable ordering, the pitfalls of OFFSET on large tables (which ties back to indexing in Databases, Volume 7). For now, the rule is simply: collections are paginated, always.
Gotcha — filtering is not authorization. GET /orders?userId=42 filters the list to user 42's orders, but it does not stop user 7 from asking for it. Query params are a client convenience, not a security boundary. The server must still check that the caller is allowed to see those orders (Part IV). Never confuse "the client asked for only their data" with "the client can only get their data."
Statelessness and cacheability
Two REST constraints do a lot of quiet work.
Statelessness means each request carries everything the server needs to handle it — identity, parameters, body — and the server keeps no per-client session memory between requests. The server may (and does) have state in the database; what it doesn't have is a "conversation" with a particular client living in one server's RAM. Why does this matter? Because it makes horizontal scaling trivial: any of your fifty servers can handle any request, since none of them holds context the others lack. A load balancer can spray requests across the fleet freely. (This is exactly why token-based auth beats server-side sessions at scale — Part IV — and it connects to the stateless-services discussion in System Design, Volume 5.)
Cacheability means responses can declare whether and how long they may be reused. Because GET is safe (it doesn't change state) and addresses are stable, a GET /orders/1001 response can be cached — by the browser, a CDN, or a reverse proxy — with headers like Cache-Control and ETag. This is only possible because the design is disciplined: stable URLs, safe reads, honest status codes. An API that returns 200 for errors or mutates state on GET poisons every cache in the path.
Think about it If a single request contains everything the server needs, you can replay it, route it to any machine, and cache its result. If instead the server "remembers" that your last request set some context, none of that works — the request is meaningless without a specific machine's memory. Statelessness is the constraint that buys you scale, resilience, and caching all at once. It is not a limitation; it is leverage.
HATEOAS and the Richardson Maturity Model
Fielding's REST includes one more constraint that most "REST APIs" quietly skip: HATEOAS — Hypermedia As The Engine Of Application State. The idea is that responses include links telling the client what it can do next, so the client navigates the API by following links rather than hardcoding URLs:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1001,
"status": "pending",
"_links": {
"self": { "href": "/orders/1001" },
"pay": { "href": "/orders/1001/payment", "method": "POST" },
"cancel": { "href": "/orders/1001", "method": "DELETE" }
}
}
In theory this makes clients resilient to URL changes and self-documenting. In practice, most APIs skip HATEOAS: JSON clients rarely "discover" links at runtime — they hardcode the endpoints they need — so the extra payload and complexity buy little. It shines in a few niches (some payment and hypermedia-driven APIs), but the pragmatic mainstream stops short of it. Know what it is, be able to define it, and don't feel obligated to build it.
A useful lens for "how RESTful is this?" is the Richardson Maturity Model, which grades an API in levels:
| Level | Name | What it means |
|---|---|---|
| 0 | The Swamp of POX | One URL, one method (POST), RPC tunneled over HTTP |
| 1 | Resources | Multiple URLs (resources), still often one method |
| 2 | HTTP Verbs | Proper methods + status codes per resource — where most good APIs live |
| 3 | Hypermedia | Level 2 + HATEOAS links |
Most well-designed production APIs sit firmly at Level 2, and that is a perfectly respectable place to be. The model is a diagnostic vocabulary, not a ladder you must climb to the top of.
Good vs. bad endpoint design
Put together, the conventions produce a clear sense of "right." Here is the same set of operations designed well and poorly:
| Intent | Bad (RPC-flavored, inconsistent) | Good (RESTful, consistent) |
|---|---|---|
| Create a user | POST /createUser → 200 { "error": null } |
POST /users → 201 + Location |
| Get a user | GET /getUser?userId=42 |
GET /users/42 |
| A user's orders | GET /orderList?u=42 |
GET /users/42/orders |
| Update email | POST /users/42/updateEmail |
PATCH /users/42 |
| Delete a user | GET /deleteUser?id=42 |
DELETE /users/42 → 204 |
| Not found | 200 { "found": false } |
404 Not Found |
| Casing | mix of userId, user_id, UserID |
one casing everywhere (e.g. userId) |
The bad column isn't broken — it works — but every row costs the reader a fresh decision, and the inconsistencies compound. The good column is boring, and boring is the highest compliment an API can earn.
Consistency is the real feature
If you take one thing from this chapter, take this: consistency beats cleverness. An API where every collection is plural, every error is a 4xx with the same JSON error shape, every field is camelCase, and every timestamp is ISO-8601 UTC is one a client can learn once and then predict forever. Pick your conventions early and enforce them everywhere:
- Naming: plural nouns, hyphenated paths, no verbs.
- Casing: choose
camelCaseorsnake_casefor JSON fields — and never mix. - Error shape: one envelope for every error, e.g.
{ "error": { "code": "not_found", "message": "...", "details": [] } }, so clients write one error handler. - Dates: ISO-8601, UTC, everywhere.
- Pagination: the same params and envelope on every collection.
A written style guide plus a review that rejects deviations is worth more than any framework. The most-loved APIs (Stripe is the standard example) win almost entirely on relentless consistency.
Common Mistakes
- Verbs in the path.
POST /createUser,GET /getOrders— the method already carries the verb. Use nouns; letPOST/GET/DELETEsupply the action. 200 OKfor errors. Returning success and hiding the failure in the body breaks caching, monitoring, and retries. Failures are 4xx/5xx, always.- Using
GETto change state.GET /orders/1001/cancellooks convenient and is a trap:GETis safe and cacheable, so proxies and prefetchers may fire it and cancel orders you never meant to. State changes usePOST/PATCH/PUT/DELETE. - Unbounded collections.
GET /ordersreturning every row will eventually time out and exhaust memory. Paginate from day one; cap the page size. - Inconsistent casing and error shapes.
user_idhere,userIdthere, three different error formats — each forces the client to special-case. One convention, everywhere. - Confusing filtering with authorization.
?userId=42narrows the list; it does not protect it. The server still authorizes the caller. - Deep nesting.
/users/42/orders/1001/items/3/reviewscouples URLs to a fragile hierarchy. Once a thing has identity, give it a top-level route.
Thought experiment You're designing an endpoint to "publish" a blog post — flip it from draft to live. The RPC instinct is POST /posts/99/publish. The purist instinct is PATCH /posts/99 with { "status": "published" }. Which is better? There's a real tension: publishing may trigger side effects (send notifications, generate a slug) that feel more like an action than a field edit, yet "status" is genuinely just a field. Argue both. (Many teams pragmatically allow an action sub-resource like POST /posts/99/publish for verbs that don't map cleanly to a field — REST as a guide, not a religion.)
GET /playlists (list), POST /playlists (create → 201), GET /playlists/:id (read), PATCH /playlists/:id (rename/partial update), DELETE /playlists/:id (delete → 204), and for the contained songs GET /playlists/:id/songs, POST /playlists/:id/songs (add), DELETE /playlists/:id/songs/:songId (remove). Plural nouns, verbs in the methods, one level of nesting.POST /playlists creates (server assigns id, not idempotent). PUT /playlists/42 replaces the entire resource with the body you send and is idempotent (repeat → same state). PATCH /playlists/42 updates only the fields you include. Use POST to create, PUT for a full replace, PATCH for a partial edit. Bonus points for noting PUT/DELETE/GET are idempotent and POST is not — which matters for safe retries.201/204/404/409, never 200 for errors), cacheable safe GETs, consistent naming/casing/error shapes, and bounded/paginated collections. Realistically, aim for a clean Level 2 — HATEOAS (Level 3) is usually skipped because JSON clients hardcode routes and the added complexity rarely pays off. "RESTful" in practice means disciplined Level 2, not dogmatic Level 3.Hands-on Extend the router below. Add a route for a nested collection — the comments on a post: GET /posts/:id/comments (list, 200) and POST /posts/:id/comments (create, 201). Then add a method-mismatch check: if a path matches an existing pattern but no route has that method, return a synthetic { status: 405 } instead of null. Confirm that PUT /posts/1 yields 405 (the pattern exists, the method doesn't) while GET /nope yields a plain not-found.
Summary
- REST is an architectural style, not a protocol: model your domain as resources (nouns) identified by URLs, and act on them with HTTP's fixed set of methods (verbs).
- Name resources as plural nouns (
/users,/users/42,/users/42/orders), lower-case and hyphenated, with no verbs in the path — the method carries the action. - Map CRUD to
GET/POST/PUT/PATCH/DELETEwith correct status codes:201+Locationon create,204on delete, honest4xx/5xxon failure — never200for an error. - Collections are paginated and refined via the query string (filter/sort/paginate); the path is identity, the query is refinement. Pagination gets full treatment in Part VI.
- Statelessness buys horizontal scale; cacheability rides on safe
GETs and honest status codes — both are why the disciplined design pays off. - HATEOAS (Level 3) is usually skipped; aim for a clean Level 2 on the Richardson Maturity Model. Above all, consistency — naming, casing, error shapes — is the feature clients feel most.
Next, we turn the request itself under the microscope: validating and parsing input so that "never trust the client" becomes concrete code — because a beautifully designed endpoint that acts on unchecked input is a beautifully designed vulnerability.
Editorial reflection Good API design is an act of empathy for people you will never meet — the frontend engineer at 11 p.m., the mobile team in another timezone, the partner integrating your service a year from now. Every consistent plural, every honest status code, every predictable URL is a small kindness that compounds into trust. The APIs engineers love are almost never the clever ones; they are the ones that never surprised them. Build to be guessable, and you build to be loved.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.