awesome-everything RU
↑ Back to the climb

APIs

Modeling REST resources: nouns the client needs, not your verbs or tables

Crux A REST API is a model of resources a client cares about. The two failures that hurt in prod: turning actions into verb endpoints, and mirroring your DB tables so every schema change breaks every client.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A migration renames users.full_name to users.display_name. Routine. The deploy goes out Friday afternoon and three mobile app versions in the wild start rendering blank name fields, because the API serialized DB rows straight to JSON and clients keyed on full_name. There is no API layer to fix — the column is the contract. Rollback the migration, ship a hotfix nobody can install for two weeks, and add “rename a column” to the list of things that now require a coordinated release.

Resources are nouns; actions are not endpoints

REST’s core move is to model your domain as resources — addressable nouns like an order, a user, an invoice — and let a small fixed set of HTTP verbs act on them. GET /orders/42 reads it, PATCH /orders/42 updates it, DELETE /orders/42 removes it. The verb lives in the method, not the URL. The instant you write POST /createOrder or GET /getUserById, you have stopped doing REST and started doing RPC with extra steps: the URL now carries a verb the HTTP method already expresses, and you have lost cacheability, the uniform interface, and every tool that reasons about resources.

Two conventions a senior treats as non-negotiable for consistency, because clients pattern-match on them:

  • Plural collection nouns: /orders, /orders/42. A collection is /orders; an item is /orders/{id}. Don’t mix /order and /orders.
  • Stable opaque IDs, not positional or guessable ones. The ID in /orders/42 is the resource’s identity; clients store and re-fetch it, so it must not change when you reorder or re-shard.

Filtering, sorting, and pagination are not new endpoints — they are query parameters on the collection: GET /orders?status=open&sort=-created_at&page=2. Inventing /openOrders and /recentOrders as separate paths is the same RPC mistake at the collection level.

The non-CRUD action problem

Real domains have actions that are not create/read/update/delete: cancel an order, publish a post, refund a payment, retry a job. The naive fix — POST /orders/42/cancel reads like a verb endpoint, and purists recoil. But this is the one place pragmatism wins, and even Microsoft’s and Thoughtworks’ guidance accepts it: a state transition that is not naturally a field update is best modeled as either a sub-resource action or a transition resource.

Two clean patterns:

  1. Action sub-resource: POST /orders/42/cancel. It is a verb in the URL, yes, but it is scoped to a resource and uses POST (non-idempotent, side-effecting), which is honest. Most large public APIs (Stripe, GitHub) do exactly this.
  2. Transition resource (the “nounify” trick): model the action itself as a resource. A refund is not a verb on a payment — it is a Refund you create: POST /payments/42/refunds. Now the refund has its own ID, status, and audit trail, and you can GET /payments/42/refunds to list them. This is strictly better whenever the action has its own lifecycle or you need a record of it.

The anti-pattern to avoid: faking a transition through PATCH /orders/42 {"status": "cancelled"}. It looks RESTful but it is a trap — you have exposed an internal state field as client-writable, so a client can set status to anything, skip the business rules that gate a real cancellation, and put the order into a state your code never expected.

You want to…Don’tDo
Create an orderPOST /createOrderPOST /orders
Fetch open orders, newest firstGET /openOrdersGET /orders?status=open&sort=-created_at
Cancel an orderPATCH /orders/42 {"status":"cancelled"}POST /orders/42/cancel
Refund a payment (auditable)POST /refundPayment?id=42POST /payments/42/refunds

Granularity: the over-nested, chatty API

Nesting expresses ownership: /orders/42/items is the items belonging to order 42. It reads well — until you keep going. /customers/7/projects/3/orders/42/items/9/discounts is a real shape teams ship, and it is a slow-motion disaster. The URLs are fragile (move an item between orders and every encoded path breaks), and worse, deep nesting forces a chatty client: to render one screen the app must walk the hierarchy — fetch the customer, then their projects, then each project’s orders, then each order’s items — which is the N+1 problem promoted to network round-trips. A dashboard that needs 8 entities becomes 8+ sequential HTTPS calls, each paying its own latency tax; at 80ms per round-trip that screen takes 640ms before any rendering.

The senior rule of thumb: nest at most one level deep, two as an absolute ceiling. Beyond that, address resources by their own ID at the root and use query parameters for the relationship. Instead of /customers/7/projects/3/orders, expose /orders?customer=7&project=3. The order has a stable identity of its own; reaching it should not require its entire ancestry. For the chatty-screen problem specifically, the answer is a coarser representation — let the client request related data with ?expand=items,customer or ?include=... so one call returns what the screen needs, instead of forcing N walks.

Why this works

Why is POST /orders/42/cancel tolerated when “verbs in URLs” is the cardinal sin? Because the alternative — a writable status field — is worse. The cancel endpoint is a guarded transition: the server runs the rules (is it already shipped? already refunded?) and either performs the whole transition or rejects it. A writable field hands the client the keys to the state machine. The verb-in-URL purity is a smaller cost than an unguarded state field.

Representation vs resource, and the leaky-abstraction trap

A resource is the conceptual thing (the order). A representation is one serialization of it (the JSON you return, an XML variant, a summary vs. a full view). The same resource can have many representations; the resource is stable, the representation is a choice. This distinction is what lets your API survive change — and ignoring it is what caused the Friday-deploy disaster in the Hook.

When the API serializes DB rows directly, the representation is the table. Now the table schema is the public contract: rename a column and you break every client; add a NOT NULL column and old writes fail; expose an internal is_deleted flag and a client starts depending on it. The fix is an explicit mapping layer — a DTO, a serializer, a view model — that translates between the persistence shape and the wire shape. It costs a few minutes per endpoint and it buys you the freedom to refactor your database without a coordinated client release. This is the single highest-leverage decision in API design: the representation is a contract you own, not an accident of your ORM.

HATEOAS is the theoretical endgame here: responses carry hypermedia links (_links: { cancel: "/orders/42/cancel" }) so clients discover available actions instead of hardcoding URLs, and the server can evolve URLs freely. In theory it is the decoupling silver bullet. In practice it is the most underused REST constraint — most “REST” APIs are level-2 Richardson (resources + verbs, no hypermedia), because client frameworks rarely consume links and the discipline rarely pays for itself outside large hypermedia-native ecosystems. Know what it is, know why it is rare, and don’t let a purist block your ship over its absence.

Pick the best fit

A screen shows a customer with their projects and each project's orders. The current API is /customers/{id}/projects/{id}/orders, and rendering takes 9 sequential calls. Pick the redesign.

Quiz

You need to let clients refund a payment, and finance wants every refund auditable with its own status. What's the cleanest model?

Quiz

Why does serializing DB rows straight to JSON in your responses bite you later?

Order the steps

Order the steps to model a 'publish a draft post' capability the RESTful way:

  1. 1 Identify the resource: a post is the noun → /posts/{id}
  2. 2 Recognise 'publish' is a state transition, not a CRUD field the client should write directly
  3. 3 Model it as a guarded action sub-resource: POST /posts/{id}/publish
  4. 4 Server runs the rules (is it already published? is the author allowed?) and performs the whole transition
  5. 5 Return the post's new representation (status: published) — mapped via a serializer, not raw DB rows
Recall before you leave
  1. 01
    A teammate wants to add 'cancel an order' as PATCH /orders/{id} with {"status": "cancelled"}. Explain why that's a trap and what to do instead.
  2. 02
    Why is 'don't serialize DB rows directly' considered the highest-leverage decision in API design, and what does the mapping layer actually buy you?
Recap

A REST API is a model of resources — nouns the client cares about — addressed by stable IDs, with the verb living in the HTTP method, not the URL. Use plural collection nouns and put filtering, sorting, and pagination in query parameters rather than minting new endpoints. Non-CRUD actions are the real test: model a state transition as a guarded action sub-resource (POST /orders/{id}/cancel) or, when it has its own lifecycle, as a transition resource (POST /payments/{id}/refunds) — never as a client-writable status field, which unlocks the state machine. Keep nesting shallow (one level, two at most) and reach for query params plus field expansion so a single screen doesn’t fan out into N sequential round-trips. Above all, serialize through a mapping layer so the representation is a contract you own, not a mirror of your DB tables — that one decision is what keeps your database refactorable without breaking every client. HATEOAS is the elegant theory of self-describing links; know it, and know why level-2 REST is what almost everyone actually ships.

Continue the climb ↑REST modeling: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.