Resource Design
Good REST APIs are designed around resources, not actions. Compare RPC-style and resource-style endpoints, decide how deep to nest, model actions that don't fit CRUD, and pick conventions you'll apply everywhere.
🎯 What you'll learn
- Nouns, not verbs
- Collections vs single items
- Nesting and relationships
- Non-CRUD actions, casing and pluralization
📚 Before you start
1Nouns, Not Verbs
The single most important rule of REST resource design: URIs name things, HTTP methods name actions. The verb belongs in the method, not the path.
BAD: POST /createOrder
BAD: GET /getOrderById?id=482
GOOD: POST /orders (create an order)
GOOD: GET /orders/482 (read order 482)If you find yourself putting a verb in a URL, it's a sign the operation should be expressed as a different HTTP method on a resource, or (see section 4) modeled as its own sub-resource.
2Collections vs. Single Items
Every resource type gets two URI shapes:
| Shape | Example | Represents |
|---|---|---|
| Collection | /orders | The set of all orders (GET = list, POST = create one) |
| Item | /orders/482 | One specific order (GET = read, PUT/PATCH = update, DELETE = remove) |
Collections are (almost) always plural nouns — /orders, not /order. This stays consistent whether the collection has 0, 1, or 10,000 members.
3Nesting and Relationships
When a resource only makes sense in the context of a parent, nest it:
GET /orders/482/items -> items belonging to order 482
GET /orders/482/items/9 -> item 9 of order 482
POST /orders/482/items -> add an item to order 482Depth guidance: keep nesting to one or two levels. /customers/7/orders/482/items/9/reviews/3 is technically consistent but painful to work with. A common pattern once you're deep enough is to "promote" the inner resource to a top-level collection reachable by its own id, and only use nesting for creation/listing in context:
GET /items/9 -> works regardless of which order it came from
GET /orders/482/items -> list items scoped to order 482 (nesting used only for "in-context" listing)Embedding vs. linking
A collection response can either embed related data inline or link to it and require a follow-up request:
// embedded — one request, larger payload, can go stale relative to the source of truth
{ "id": 482, "customer": { "id": 7, "name": "Ana" } }
// linked — smaller payload, always fresh, costs an extra request
{ "id": 482, "customer": { "href": "/customers/7" } }Neither is "more correct" — it's a trade-off between request count and payload size/freshness, often solved with an opt-in ?expand=customer query parameter that lets the client choose per-request.
4Actions That Don't Fit CRUD
Not every operation maps cleanly to create/read/update/delete. "Cancel this order," "reset this user's password," "publish this article" are actions, not resource replacements. Two accepted patterns:
Controller sub-resource (most common, recommended default):
POST /orders/482/cancel
POST /users/7/password-reset
POST /articles/55/publishThis models the action as a POST to a URI that reads like "the cancellation of order 482" — technically still a noun (the act of cancelling), and it composes cleanly with HATEOAS links (section 5 of REST Fundamentals).
State-changing PATCH (works when the action is really just a field transition):
PATCH /orders/482
{ "status": "cancelled" }This works well when the action is simply "change this one field," but breaks down once the action needs side effects beyond setting a field (e.g. cancelling also needs to trigger a refund) — at that point the controller sub-resource pattern communicates intent more clearly.
5Casing and Pluralization Conventions
| Convention | Example | Notes |
|---|---|---|
| lowercase, hyphenated segments | /order-items | Most common for multi-word resource names; avoids case-sensitivity bugs across systems |
| plural collection names | /orders, /customers | Consistent even for a collection that only ever has one row (/settings is a common, deliberate exception) |
| no trailing slash | /orders not /orders/ | Pick one and be consistent — most frameworks treat these as distinct routes |
| identifiers own their own segment | /orders/482 not /orders?id=482 | The path identifies which resource; query strings are reserved for filtering/pagination/sorting (its own topic) |
Under the HoodHow a Router Actually Matches a Path to a Resource▾
Resource design isn't just a naming convention — it's compiled into real matching logic by the framework serving the API. Understanding that mechanism explains why certain design choices (ambiguous nesting, overlapping patterns) cause real bugs.
Path templates, not strings
A route like /orders/:id/items/:itemId (Express-style) or /orders/{id}/items/{itemId} (OpenAPI/RFC 6570 URI Template-style) is compiled — usually at server startup — into a matcher, most commonly a regular expression or a trie (prefix tree) keyed by path segment:
Route table (compiled once, matched per request):
/orders -> regex ^\/orders\/?$
/orders/:id -> regex ^\/orders\/([^/]+)\/?$
/orders/:id/items -> regex ^\/orders\/([^/]+)\/items\/?$
/orders/:id/items/:itemId -> regex ^\/orders\/([^/]+)\/items\/([^/]+)\/?$At request time, the incoming path /orders/482/items/9 is tested against each compiled pattern (or walked through the trie one segment at a time) until one matches; the captured groups (482, 9) are extracted into req.params.
Specificity ordering resolves ambiguity
Two patterns can both match the same literal path — e.g. a static route /orders/summary and a dynamic route /orders/:id both match /orders/summary. Frameworks resolve this by registration order (Express: first-registered-wins) or by an explicit specificity score (some routers rank literal segments above dynamic ones automatically, regardless of registration order). This is precisely why resource design guides tell you to register static sub-paths (/orders/summary, /orders/export) before the generic /orders/:id pattern — if :id is checked first, a request for /orders/summary gets incorrectly captured as id = "summary".
Trie-based routers scale better than a sequential regex scan
High-throughput frameworks (e.g. Fastify's find-my-way) build a radix trie over route segments instead of testing every route in registration order. Matching becomes a walk of depth equal to the number of path segments (O(segments)) rather than a linear scan over every registered route (O(routes)) — this is why deeply nested but well-structured resource hierarchies (section 3's guidance to cap nesting at 1-2 levels) matter for router performance at scale, not just for readability.
⚠️ Common Pitfalls
- Verbs in the URL —
/getOrder,/orders/482/delete— the method already carries the verb; repeating it in the path is redundant and inconsistent once mixed with proper resource paths. - Inconsistent pluralization — mixing
/order/482and/orders/999across the same API confuses consumers and complicates client SDK generation. - Over-nesting —
/customers/7/orders/482/items/9/reviews/3/comments/1— walk it back; promote deeply nested resources to top-level, scoped by query parameter or a single level of nesting for creation. - Registering a dynamic route before a static one that overlaps it — causes the static route to be silently shadowed (section 6).
- Using query parameters for identity (
/orders?id=482) instead of the path (/orders/482) — breaks caching (the path is the cache key for many HTTP caches) and REST's "resource identified by URI" constraint. - Modeling every action as a
PATCHfield flip when the action has side effects beyond that one field — use a controller sub-resource instead (section 4).
📋 Quick Reference
Collection: plural noun /orders
Item: collection + id /orders/482
Nested: parent/id/child /orders/482/items
Action: controller sub-resource /orders/482/cancel (POST)
Casing: lowercase, hyphenated multi-word segments
Identity: in the path, never in the query string
Nesting depth: cap at 1-2 levels; promote deep resources to top-level🧠 Key takeaways
- Paths name resources (plural nouns); methods are the verbs.
/orders= collection,/orders/{id}= item; nest at most one level for ownership.- Model complex actions as sub-resources or event nouns (
/cancellations), never unsafe GETs. - Pick conventions (plural, kebab-case paths, one JSON casing) and enforce them with a linter.
🎤 Interview Q&A
Q1How do you name REST resources?core▾
Plural nouns, lowercase with hyphens, hierarchical where there's real ownership: /users/{id}/orders, /orders/{id}/items. No verbs in paths (the method is the verb), and avoid deep nesting beyond two levels.
/getUsersvs/users?/userswith GET.
Q2How do you model actions that aren't CRUD?core▾
As sub-resources or state transitions: POST /orders/{id}/cancel, POST /payments/{id}/refunds (creating a refund resource), or by changing a field with PATCH (status=cancelled) when it's a simple transition.
- Which is preferable for refunds? Creating a refund resource, since it has its own data and lifecycle.
Q3How should related resources be returned?core▾
Return IDs or links by default, and allow expansion (?include=customer or ?expand=items) to avoid over-fetching and N+1 calls from clients. Keep payloads consistent across endpoints.
- Risk of always embedding? Large payloads and tight coupling to internal models.
Q4Should resource IDs be sequential integers?senior▾
Externally, prefer non-guessable IDs (UUIDs, prefixed IDs like ord_3fK…) to avoid enumeration and leaking business volume. Internally, integers are fine. Always enforce authorization regardless.
- Why prefixed IDs? They make IDs self-describing in logs and support (Stripe style).
Q5Should your API resources mirror database tables?core▾
No. Resources are designed for clients' use cases and can aggregate or hide tables. Exposing tables directly couples clients to your schema and leaks internals.
- How do you decouple them? DTOs/response models mapped from domain entities.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.