Modelling the resources before the endpoints
Get the nouns right and the endpoints mostly write themselves; get them wrong and you pay in round trips, in names nobody can say out loud, and in fields that mean two things.
The idea
Before you write a single route, write down the things your domain actually has — the ones with an identity that outlives a request, a lifecycle of their own, and someone who cares when they change. Those are your resources. Endpoints are what falls out afterwards.
A boundary drawn in the wrong place doesn't cause a philosophical problem, it sends you a bill. Children with no parent-scoped collection cost you a round trip each. A lifecycle folded into a parent's field makes that field mean two things. An action with no noun becomes an endpoint nobody can name out loud.
Drag the domain into shape
One shop domain: a customer places orders. Each thing below can be a top-level resource, a sub-resource of the order, or just a field on the order document. Move them and watch the endpoints — and the symptoms — regenerate.
Drag a box between the three bands, or use the buttons below.
the surface it generates
symptoms
How it works
A repeatable sequence. It takes ten minutes on a whiteboard and saves a quarter of migration.
- Say the domain out loud. Write down every noun a domain expert uses in one paragraph of description. Not your table names — their words.
- Test each noun for resourcehood. Does it have an identity that survives the request? Its own lifecycle or state machine? Does anyone need to link to it, cache it, permission it, or change it on its own? Three yeses means resource; zero means field.
- Choose the shape. Field on the parent if it can't exist alone and is never edited alone. Sub-resource if it has its own life but can't outlive its parent. Top-level if several parents reference it, or it outlives them.
- Separate paths from filters. A path segment names a set the domain really has; a query parameter narrows a set that already exists.
/customers/9/ordersis a real set — Ada's orders.?status=openis a view of one. If you'd need a new path for every value, it was a parameter. - Name the non-CRUD actions. Ask what the action leaves behind: a cancellation, a refund, a transfer, a session, a retry attempt. That residue has a time, an author and a reason, so it's a resource you
POST. Only when there is honestly no noun do you fall back to a verb sub-resource — and even then keep it under the thing it acts on. - One surface, two audiences. Admin and end user get the same paths. What differs is authorisation (which rows), projection (which fields) and which filters are permitted. Never a second API.
The round-trip arithmetic for rendering one order page — an order, n lines, a payment, a shipment:
children as top-level resources, parent hands back ids
calls = 1 + n + 1 + 1 n=3 -> 6 n=40 -> 43
children as sub-resources (parent-scoped collections)
calls = 1 + 1 + 1 + 1 n=3 -> 4 n=40 -> 4
everything embedded in the order document
calls = 1 n=3 -> 1 n=40 -> 1
but every edit is PUT /orders/42 with the whole document,
so two concurrent edits = one silently lost
Note what the middle column is not: the smallest number of endpoints. Nesting adds routes. The goal is endpoints you can name, not fewer of them.
When to use it
| Situation | Shape | Trade-off you accept |
|---|---|---|
| Read only with the parent, never edited alone (an address snapshot, a money amount) | Field on the parent | Whole-document writes; no per-child permissions or concurrency control |
| Own id, timestamps or state machine, but cannot outlive the parent (payments, shipments, comments) | Sub-resource | Deeper paths; you must decide what happens on parent delete |
| Referenced by several parents, or lives longer than any of them (customers, products, price lists) | Top-level resource | A second call unless you support ?parentId= or field expansion |
| Narrowing a collection you already have (open, last 30 days, mine) | Query parameter | Filters are cheap to add and sprawl fast — cap them, document the defaults |
| Something happened, with a time, an author and a reason (cancellation, refund, retry) | POST a sub-resource for the event | Conflict semantics (409) and keys become your job |
Watch out for
- One
statusfield stacking several lifecycles. Payment, fulfilment and cancellation each have their own state machine. Squeeze them into one string and the first order that is paid and partly shipped forces you to inventpaid_shipped_partial. Give each lifecycle its own resource, or at minimum its own field. - A child resource with no parent-scoped collection.
GET /line-items/{id}plus a parent that returns ids is the classic N+1: cost grows with your biggest customer's data. Either nest it, or support?orderId=and expansion. Nesting has a second benefit — the URL itself says the child cannot outlive the parent. - Nesting past two levels.
/customers/9/orders/42/line-items/7/discounts/2— nobody can construct that URL, and your handler ignores the middle ids anyway. Once a child has a globally unique id, address it directly and stop nesting. - Verbs smuggled into the request body.
POST /orders/42with{"action":"cancel"}gives you one endpoint with many meanings: you can't authorise, rate-limit, audit or cache it per action, and the access log tells you nothing. - Forking an
/adminmirror. Two paths for one concept drift within a quarter — a field gets added to one copy, a validation rule to the other. Keep one path and vary authorisation, projection and permitted filters. If the admin genuinely needs a different concept (a refund queue, a fraud review), that's a new resource, not a mirror.
Worked example
The prompt is design the API for a food delivery app, and the tempting first move is to start listing routes. Ask about the domain instead: a diner places an order with a restaurant; the order has items; a delivery is dispatched to a courier; along the way things happen — accepted, collected, handed over.
Items never exist without their order and nobody links to one, so they're /orders/{id}/items — one call, and the path encodes the ownership. A delivery has its own id, its own courier, its own state machine and can be reassigned mid-flight, so it is a resource, not an order.status value; that split is what stops one field meaning both "the kitchen accepted" and "the courier arrived". "Courier arrived at restaurant" isn't an update to a field either — it's an event with a timestamp and a source, so POST /deliveries/{id}/events, and the delivery's current state is derived from them.
Then the interviewer asks about the dispatcher console. Same paths: the dispatcher reads GET /orders/{id} exactly as the diner does, but their token unlocks ?restaurantId= on the collection and a few operational fields in the projection. When the follow-up is "now support scheduled orders", you're adding a field or a sibling resource — not a second API.
Check yourself
A support agent must see every order; a customer only their own. What do you build?
Not quite — two paths for one concept. They start identical and drift the first time a field is added to only one of them, and now every client has to know which URL it is allowed to say.
Yes. One resource, one path. Authorisation decides which rows, projection decides which fields, and the extra filter is simply permitted for a role that has earned it.
Not quite — a client should never declare its own authority. The role comes from the token; a query parameter that changes permissions is a query parameter an attacker will try.
Cancelling an order must record a reason, notify the kitchen, and may trigger a refund. Which shape fits?
Not quite — the reason has nowhere to live, and the same endpoint now both fixes a typo in the delivery note and cancels an order. Your audit log can't tell those apart, and neither can your rate limiter.
Yes. The cancellation is the thing that happened: a time, an author, a reason, maybe a refund attached. A repeat call returns 409 rather than quietly cancelling twice.
Not quite — it works, but the thing you're acting on has left the path. You lose per-order authorisation at the routing layer, and nothing stops a body pointing at an order that isn't yours or doesn't exist.