Resource modeling & URI design
Published
Why this, first. In a whiteboard interview the first artifact you produce is the resource model and its URIs — before status codes, before auth, before pagination. Clean modeling, and defending nouns-vs-verbs and your nesting depth on the spot, is one of the earliest senior signals an interviewer reads. Get this frame right and the rest of the design hangs off it cleanly.
URIs name nouns
the method is the verb
nest shallow
ids are opaque
GET, POST, PATCH, DELETE) supplies the verb.
The trap: verbs in the path
The fastest way to flag yourself as mid-level is to draw /getOrder,
/createOrder, /cancelOrder. That’s RPC in a REST costume: the verb is
in the path and the HTTP method is now decoration. Name the noun; let the
method be the verb acting on it.
1
✗ Myth: “REST just means JSON over HTTP, so POST /createOrder is fine.”
✓ Truth: the URI names a noun; the method is the verb — that uniformity is what makes the contract cacheable and predictable.
Modeling actions that aren’t CRUD
Plain CRUD maps cleanly onto methods. It gets interesting at a genuine verb — cancel, refund, publish, retry — that doesn’t reduce to create/read/update/delete. Three principled moves; the senior signal is naming the trade-off, not just picking one.
a · Sub-resource Model the outcome as a thing:
POST /orders/42/cancellations. Stays purely resource-oriented; the cancellation
becomes a first-class record you can audit and list.
b · Custom method (AIP) A colon-suffixed verb on the resource:
POST /orders/42:cancel. Honest that it’s non-CRUD, keeps the resource in the
path, explicitly sanctioned for verbs that don’t fit.
1
c · Reify as a resource Promote the verb to a noun: POST /refunds with
{order_id}. Best when the action has its own lifecycle, state, and history
— it earns standalone existence.
The tell either way Whichever you pick, the resource still lives in the path and the
method stays standard — you never invent a free-floating /doThing.
The trade-off to say out loud: purists reify state into resources; pragmatists allow a custom method for a genuinely non-CRUD verb. Pick by lifecycle — durable state and history → reify; a momentary transition on an existing resource → sub-resource or custom method beats inventing a noun nobody queries.
Nesting: containment, but shallow
Nesting communicates a real relationship: /customers/1/orders reads as “the orders
belonging to customer 1.” Legitimate. The mistake is going deep —
/customers/1/orders/42/items/9 hard-codes a whole hierarchy into the URL, and the
day a relationship changes, every client breaks.
2
/orders?customer_id=1 over deep paths. Reserve deep paths for genuine
ownership where the child has no identity of its own.
Identifiers, relationships, conventions
Three more decisions an interviewer expects you to have opinions on.
Identifiers — prefer opaque Sequential ints leak business volume (order #100002 reveals throughput) and invite enumeration / BOLA. UUIDs or ULIDs are opaque and non-guessable. Slugs are for humans/SEO, never stable keys. (Deep dive in Lesson 8.)
Relationships — link, embed, or separate Link via hypermedia (HATEOAS), embed/expand
on demand (?expand=customer / ?fields=), or expose separate
endpoints. Over-embedding bloats payloads; under-embedding makes clients chatty with N+1
round-trips.
Conventions — boring on purpose Lowercase, hyphen-separated, no trailing slash, no file extensions. Query params carry filtering / sorting / pagination — never identity.
Singleton sub-resources Not everything is a collection.
/users/me/settings is a singleton — one per user, no id, no plural. Modeling it
as /users/me/settings/1 tells the interviewer you reached for the pattern
reflexively.
🧭 Memory rule: URI names a noun, method is the verb; reify only when the action has its own lifecycle; nest ~1 level and filter for the rest; ids opaque; consistency across the whole surface beats local cleverness.
Retrieval practice
Close the lesson in your mind first, then answer from memory. Effortful recall is what converts “I read it” into “I own it.” Immediate feedback below.
Q1. Why is POST /orders/createOrder considered an anti-pattern?
Q2. Reifying a "refund" as POST /refunds is the strongest choice when…
Q3. The risk of deep nesting like /customers/1/orders/42/items/9 is that it…
Q4. Opaque identifiers (UUIDs/ULIDs) are preferred over sequential ints because they…
Rehearse the answer out loud
Interviewer: "Sketch the resources and URIs for an e-commerce checkout: browsing a cart, placing an order, cancelling it, and refunding." ~60 seconds, from memory (no scrolling up) — then reveal and compare. Don't read the model first.
“Everything’s a noun, collections plural. The cart is /carts/{id} with line
items at
/carts/{id}/items/{id}
— one level of nesting, real containment. Placing an order is POST /orders; the
server assigns the id and returns 201 with a Location header,
building the order from the cart. I’d require an idempotency key on order creation so a
retry doesn’t double-charge.
Cancelling is the interesting one. I’d model it as
POST /orders/{id}/cancellations or, AIP-style,
POST /orders/{id}:cancel — cancellation is a transition on an existing
order, not a thing with its own life, so I wouldn’t invent a top-level noun. A refund is
different: it has real lifecycle — processed, settled, reversible — so I’d
reify it as POST /refunds with {order_id},
alongside /payments. Ids are opaque (UUID/ULID) so order numbers don’t leak
volume or invite enumeration. For a customer’s orders I’d filter —
/orders?customer_id= — rather than nest deeply.”
Why this scores: nouns not verbs, principled action modeling with a stated trade-off (custom method vs. reified resource, chosen by lifecycle), shallow nesting plus filtering, and security-aware opaque ids — judgment, not memorized rules.
I’m your teacher — ask me anything. Want to argue custom methods vs. reified
resources for a specific action? Curious how ?expand= vs. separate endpoints plays
out under load? Want me to grade your checkout sketch or throw a nastier modeling prompt at you?
Just say so in the chat.
Primary source (read this next)
📖
“Resource-oriented design” — Google AIP-121: the clearest high-trust treatment of nouns, collections, and custom methods. Pair it with
Microsoft’s API design best practices
for conventions and nesting guidance.
Interview — pick your bar
Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).
Why is POST /getOrder an anti-pattern?
It encodes the verb in the path, which is RPC wearing a REST costume. In resource-oriented design the URI names a noun — the thing — and the HTTP method is the verb. The read should be GET /orders/42: the resource is /orders/42, the action is GET.
What is the difference between a collection URI and an item URI?
A collection URI (/orders) addresses the set: GET lists it, POST adds to it (server assigns the id). An item URI (/orders/42) addresses one member: GET reads it, PUT/PATCH updates it, DELETE removes it. The method's meaning shifts with which level you're at.
What does nesting like /customers/1/orders communicate?
Containment — "the orders belonging to customer 1." That's a legitimate, readable use of nesting for a real ownership relationship. The mistake isn't nesting one level; it's going deep.
Why prefer opaque identifiers over sequential integers in URIs?
Sequential ints leak business volume (reading order #100002 tells an attacker your throughput) and invite enumeration — walking /orders/1, /orders/2… UUIDs/ULIDs are opaque and non-guessable, so neither attack works from the id alone.
Why does the noun/verb discipline matter beyond aesthetics?
Because it's what keeps the contract uniform and cacheable. Once verbs leak into paths, every endpoint becomes a bespoke procedure and the HTTP machinery — caching, idempotency, conditional requests, status semantics — stops applying. GET /orders/42 is cacheable and safe by definition; POST /getOrder tells intermediaries nothing. Naming the resource correctly is the load-bearing decision the rest of the design hangs off.
What are the three principled ways to model a non-CRUD action?
- Sub-resource / state collection: treat the outcome as a thing —
POST /orders/42/cancellations, or a status you read back. - Custom method (Google AIP): colon-suffixed verb on the resource —
POST /orders/42:cancel. - Reify the action as its own resource: promote the verb to a noun —
POST /refundswith{order_id}.
The senior signal is naming the trade-off, not just picking one.
Why is deep nesting like /customers/1/orders/42/items/9 fragile?
It hard-codes the entire hierarchy into the URL. The day a relationship changes — items move under a fulfillment, an order spans customers, you add line-item history — every client URL breaks. Deep paths couple your contract to today's org chart. They also bloat with redundant ids you don't need to locate the leaf.
What is a BOLA / IDOR vulnerability and how does id choice relate to it?
Broken Object-Level Authorization (a.k.a. IDOR): a user swaps the id in /orders/42 for /orders/43 and reads someone else's data because the server checks authentication but not ownership. Opaque ids make resources harder to guess, but they are not the fix — they're defense in depth. The real fix is an authorization check on every object access ("does this caller own this order?"). Calling opaque ids a substitute for authz is a junior mistake.
Articulate the pure-REST vs pragmatist tension on custom methods.
Purists argue everything should be a resource: a custom method like :cancel smuggles a verb back into the URI and breaks the uniform interface, so they'd reify state into resources (a cancellation record, a status sub-resource). Pragmatists accept a colon-method for a genuinely non-CRUD verb because inventing nouns nobody queries is its own cost. The staff answer doesn't pick a religion — it picks by lifecycle: durable state and history → reify; momentary transition → sub-resource or custom method. Say the trade-off out loud; that's the signal.
What are the downsides of a flexible ?expand= / field-selection mechanism?
It pushes you toward GraphQL-shaped problems without GraphQL's tooling: every expansion combination is a distinct query and cache key, so caching fragments; arbitrary expansion enables expensive N+1 / fan-out queries you must bound; authorization must be enforced per expanded field, not just on the root; and response shape becomes dynamic, complicating client typing and contract tests. Mitigate with an allowlist of expandable relations, depth/complexity limits, and per-field authz. At some point if clients need fully arbitrary shapes, that's the signal to consider GraphQL.
How does URI shape interact with object-level authorization?
Identity-in-path makes the authorization target explicit: every /orders/{id} access needs an ownership check (BOLA defense), and consistent URI structure lets you enforce that with uniform middleware rather than ad-hoc checks. Aliases like /users/me remove a whole class of IDOR by not letting clients name other principals. Nesting can imply scope (/customers/1/orders) but you must still verify the caller may act as customer 1 — the path is a claim, not proof. Flat URIs + per-object authz is usually cleaner than relying on nesting for security.
- Clarify the domain & list candidate nouns. Name the things the domain holds (order, cart, customer, payment, refund). Those are your candidate resources before any URL is drawn.
- Top-level collections vs owned sub-resources, by identity. Ask "does this have identity on its own?" If yes it's a top-level collection; if it only exists inside a parent, it's an owned sub-resource (one level).
- Map verbs to HTTP methods; place non-CRUD verbs deliberately. CRUD → standard methods. For genuine verbs (cancel, publish, refund) choose sub-resource, custom method (
:verb), or reify-as-resource — driven by lifecycle, not grammar. - Relationships: one-level nesting for ownership, filtering otherwise. Nest
/customers/1/ordersonly for true containment; express the rest with/orders?customer_id=1. Reify many-to-many joins that carry data. - Identifiers: opaque external ids + per-object authz. Use UUID/ULID, never the raw DB key. Opaque ids are defense in depth, not the authz fix — every object access still needs an ownership (BOLA) check.
- Conventions & consistency. Lowercase, hyphenated, plural collections, no extensions; query carries filter/sort/page, never identity. Predictability across the surface beats local cleverness.
- Evolvability / versioning cost. Stable nouns, identity-in-path, and external ids let you add fields and expansions without breaking clients — name where each choice would force a version bump later.
Model an e-commerce checkout (cart, place order, cancel, refund) on a whiteboard.
A strong answer covers: Cart is /carts/{id} with line items at /carts/{id}/items/{id} — one level, real containment. Place order: POST /orders, server assigns id, returns 201 + Location, built from the cart; require an Idempotency-Key so a retry doesn't double-charge. Cancel is a transition on an existing order, so POST /orders/{id}/cancellations or AIP-style POST /orders/{id}:cancel — no top-level noun. Refund has its own lifecycle (processed/settled/reversible) so reify it: POST /refunds with {order_id}, beside /payments. Ids opaque (UUID/ULID); list a customer's orders via /orders?customer_id= rather than deep nesting. That hits nouns-not-verbs, principled action modeling with a stated trade-off, shallow nesting + filtering, and security-aware ids.
Design the resource model for a multi-tenant project-management API with users, projects, tasks, comments, and memberships.
A strong answer covers: Top-level collections by identity: /projects, /tasks, /users — each has its own life. Scope tenancy implicitly from the authenticated principal (resolve the tenant from the token), not via a /tenants/{id}/… prefix clients can tamper with. Tasks belong to a project, but keep nesting shallow: /projects/{id}/tasks for the owned list, and address a single task flatly by identity as /tasks/{id} so it doesn't break if it's re-homed; filter cross-cuts with /tasks?assignee=…&status=open. Comments are owned by their parent and have no independent life, so /tasks/{id}/comments (one level). Membership is a many-to-many between users and projects that carries data (role, joined-at), so reify the join: /memberships (or /projects/{id}/members as a filtered view) rather than deep-nesting under one side. Ids opaque (UUID/ULID); enforce per-object authorization on every access (BOLA) and tenant isolation so user A can't read project B in another tenant — the path is a claim, not proof. Use /users/me for the caller. Conventions consistent throughout; ?expand= bounded by an allowlist if clients need related data inline.
📄 Keep handy: REST design reference
Next ▸ Lesson 5
These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.