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.

Starting from a deliberately naive model: everything that felt small got flattened, everything that felt separate got its own top-level URL. Press step to refactor it one move at a time.
0endpoints on the surface
0calls to render one order (3 lines)
0same page, 40 lines
0symptoms showing

the surface it generates

symptoms

How it works

A repeatable sequence. It takes ten minutes on a whiteboard and saves a quarter of migration.

  1. Say the domain out loud. Write down every noun a domain expert uses in one paragraph of description. Not your table names — their words.
  2. 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.
  3. 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.
  4. 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/orders is a real set — Ada's orders. ?status=open is a view of one. If you'd need a new path for every value, it was a parameter.
  5. 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.
  6. 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

SituationShapeTrade-off you accept
Read only with the parent, never edited alone (an address snapshot, a money amount)Field on the parentWhole-document writes; no per-child permissions or concurrency control
Own id, timestamps or state machine, but cannot outlive the parent (payments, shipments, comments)Sub-resourceDeeper 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 resourceA second call unless you support ?parentId= or field expansion
Narrowing a collection you already have (open, last 30 days, mine)Query parameterFilters 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 eventConflict semantics (409) and keys become your job

Watch out for

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.