Chapter 3: Resource Design — Nouns, Not Verbs
"A URL is not a function call. It is an address.
You don't call/getUserById. You visit/users/42."
1. Physical Reality First
Your database has tables. Your code has functions. Your API has resources.
The mistake most engineers make: they design APIs that mirror their functions.
❌ Function-first (RPC-style):
POST /getUser
POST /createOrder
POST /cancelOrder
POST /getUserOrders
POST /updateUserEmail
These are function calls dressed up as URLs. The URL is carrying the verb — getUser, createOrder, cancelOrder — which means HTTP's built-in verbs (GET, POST, PUT, DELETE) become meaningless. Everything is POST. Caching breaks. Semantics break. Clients can't reason about what the API does without reading docs for every single endpoint.
REST says: the URL identifies a resource (thing). The HTTP method says what to do with it.
✅ Resource-first (REST):
GET /users/42 → get user 42
POST /orders → create an order
POST /orders/99/cancel → cancel order 99 (more on this below)
GET /users/42/orders → get user 42's orders
PATCH /users/42 → update user 42's email (among other fields)
This chapter is about making that translation — from domain concepts to URLs — systematically and consistently.
2. Resources Are Nouns
A resource is any thing your API exposes. Not an action. A thing.
The test: can you point to it? Can it have an identifier? Can it persist over time?
| ✅ Resources (nouns) | ❌ Not resources (verbs) |
|---|---|
| user | getUser |
| order | createOrder |
| payment | processPayment |
| session | login |
| product | searchProducts |
| invoice | sendInvoice |
Some things that feel like verbs can be modeled as resources:
| Action | Resource model |
|---|---|
| Cancel an order | POST /orders/99/cancel → creates a "cancellation" event |
| Send an invoice | POST /invoices/12/deliveries → creates a "delivery" resource |
| Follow a user | PUT /users/42/followers/me → creates a "follower" relationship |
| Login | POST /sessions → creates a "session" resource |
| Publish a post | PATCH /posts/7 with {"status": "published"} → state transition |
The pattern: reify the action as a resource. Every action has a result — model the result, not the action.
3. URL Structure — The Rules
Rule 1: Use plural nouns for collections
✅ /users /orders /products
❌ /user /order /product
Why plural? A collection (/users) and an item (/users/42) share a base path. Plural makes both natural. GET /users = all users. GET /users/42 = one user. Consistent.
Rule 2: Use lowercase, hyphen-separated words
✅ /blog-posts /product-categories /user-profiles
❌ /blogPosts /ProductCategories /user_profiles
URLs are case-sensitive by spec. Lowercase avoids bugs. Hyphens are word separators in English text (and readable). Underscores are invisible when URLs are underlined. CamelCase is a programming convention, not a URL convention.
Rule 3: Identifiers go in the path, not the query string
✅ GET /users/42
❌ GET /users?id=42
The path identifies the resource. The query string modifies the request (filter, sort, paginate). id=42 is not a modification — it's an identity. Put it in the path.
Rule 4: Query strings for filtering, sorting, pagination
GET /orders?status=pending
GET /products?category=electronics&min_price=100&max_price=500
GET /users?sort=created_at&order=desc&page=2&per_page=20
GET /search?q=laptop&brand=apple
These modify what set of resources you get back, not which resource you're addressing.
Rule 5: No file extensions in URLs
✅ GET /users/42
Accept: application/json
❌ GET /users/42.json
Use content negotiation (Chapter 1: Accept header). The URL identifies the resource, not the format. The same resource might be available as JSON or XML — the URL shouldn't encode that.
Rule 6: No trailing slashes
✅ /users/42/orders
❌ /users/42/orders/
Pick one and be consistent. Trailing slash implies a directory. Your API has resources, not directories. Some frameworks treat them differently. Standardize on no trailing slash.
4. Nesting: When to Nest, When to Keep Flat
The central tension in resource design: nested vs flat URLs.
Nested: /users/42/orders/99/items/7
Flat: /items/7
When to nest (max 2 levels deep)
Nest when the child resource only makes sense in the context of the parent.
/users/42/addresses ← Address belongs to exactly one user
/orders/99/line-items ← Line items belong to one order
/posts/15/comments ← Comments belong to one post
/repos/torvalds/linux/issues ← Issues belong to one repo (GitHub model)
The test: can the child exist independently? Can you list all addresses across all users? If yes, consider keeping it flat or having both.
When to keep flat (or have both)
Keep flat when the child resource has meaning independent of the parent.
/orders?user_id=42 ← All orders, filtered by user
/orders/99 ← Direct access to order 99 (no need to know the user)
If you nested orders: GET /users/42/orders/99 — now the client must know both the user ID and the order ID to fetch a single order. That's needless coupling. A client that has an order ID (from a notification, a search result, a webhook) has to also fetch the user ID just to construct the URL.
Rule of thumb: Nest for creation and contextual listing. Keep flat for direct access.
POST /users/42/orders ← Create order for user 42 (nested makes sense)
GET /users/42/orders ← List user 42's orders (nested makes sense)
GET /orders/99 ← Fetch order 99 directly (flat is better)
PATCH /orders/99 ← Update order 99 (no need for user in URL)
DELETE /orders/99 ← Delete order 99 (flat)
Maximum nesting depth: 2
✅ /users/42/orders
✅ /orders/99/line-items
❌ /users/42/orders/99/line-items/7/variants
Deep nesting creates brittle URLs. If any parent ID changes or is restructured, all deep URLs break. Past 2 levels, flatten and use query params.
5. Modeling Non-CRUD Actions
REST maps cleanly to CRUD: Create (POST), Read (GET), Update (PUT/PATCH), Delete (DELETE).
But real systems have actions that don't fit:
- Cancel an order
- Publish a draft
- Approve a refund
- Resend a verification email
- Archive a user
- Lock an account
Strategy 1: Model as a sub-resource (best for significant state transitions)
POST /orders/99/cancellation → create a cancellation resource
POST /invoices/12/payments → create a payment against an invoice
POST /users/42/email-verifications → trigger email verification
GET /orders/99/cancellation → get cancellation details (reason, timestamp)
Why: the action creates something that persists (a cancellation record, a payment). You can GET it, reference it, audit it.
Strategy 2: Model as a state transition via PATCH
PATCH /orders/99
{"status": "cancelled"}
PATCH /posts/15
{"status": "published"}
PATCH /users/42
{"locked": true}
Why: when the action is simply moving a resource through a state machine. No separate resource is created; just a field changes.
Strategy 3: Use a controller sub-resource (pragmatic fallback)
POST /orders/99/cancel
POST /orders/99/refund
POST /emails/55/send
POST /reports/3/generate
This is the pragmatic approach when neither of the above fits cleanly. The sub-resource is a verb — which breaks pure REST — but it's readable, explicit, and widely used. Stripe uses this:
POST /payment_intents/pi_123/confirm
POST /payment_intents/pi_123/capture
POST /payment_intents/pi_123/cancel
Which to use: Strategy 1 when you need audit history. Strategy 2 when it's a simple field change. Strategy 3 when it's a complex action with no obvious resource model.
6. Singleton Resources
Some resources have no collection — there's exactly one instance per context.
GET /users/42/profile ← Each user has exactly one profile
PUT /users/42/profile ← Replace it
GET /account/settings ← Settings for the current authenticated user
PUT /account/settings
GET /cart ← The current user's cart (implicit user from token)
POST /cart/items ← Add to cart
No ID needed when there's only one. GET /users/42/profile — there's no /users/42/profiles/1. Profile is a singleton child.
7. Batch Operations
What if a client needs to delete 500 items? Sending 500 DELETE requests is inefficient.
Option 1: Filter-based bulk operation
DELETE /orders?status=cancelled&created_before=2024-01-01
Dangerous — easy to accidentally delete too much. Require confirmation pattern.
Option 2: ID list in request body
POST /orders/bulk-delete
{"ids": [99, 100, 101, 102]}
Explicit. Auditable. POST because DELETE traditionally has no body (though spec allows it).
Option 3: Batch endpoint
POST /batch
[
{"method": "DELETE", "path": "/orders/99"},
{"method": "PATCH", "path": "/orders/100", "body": {"status": "shipped"}},
{"method": "GET", "path": "/orders/101"}
]
Facebook Graph API and Google use this. One HTTP request, multiple operations. Complex to implement (partial failure handling, ordering guarantees) but powerful.
For most APIs: Option 2 is the right pragmatic choice.
8. Real-World URL Design Examples
GitHub:
GET /repos/{owner}/{repo} ← repo resource
GET /repos/{owner}/{repo}/issues ← issues collection
POST /repos/{owner}/{repo}/issues ← create issue
GET /repos/{owner}/{repo}/issues/{number} ← specific issue
POST /repos/{owner}/{repo}/issues/{number}/comments ← comment on issue
POST /repos/{owner}/{repo}/forks ← fork (creates fork resource)
PUT /user/starred/{owner}/{repo} ← star a repo (idempotent)
DELETE /user/starred/{owner}/{repo} ← unstar
Stripe:
POST /customers ← create customer
GET /customers/{id} ← get customer
POST /customers/{id}/sources ← add payment source
POST /payment_intents ← create payment intent
POST /payment_intents/{id}/confirm ← controller sub-resource
POST /payment_intents/{id}/capture
POST /payment_intents/{id}/cancel
GET /charges?customer={id}&limit=10 ← flat, filtered
Shopify:
GET /admin/api/2024-01/products.json ← versioned (bad practice, see Ch7)
POST /admin/api/2024-01/orders/{id}/fulfillments ← nested creation
POST /admin/api/2024-01/orders/{id}/cancel ← controller sub-resource
9. Vocabulary Map
| Term | Definition |
|---|---|
| Resource | Any identifiable thing exposed by an API. Modeled as a noun, not a verb. |
| Collection | A set of resources of the same type. /users is a collection. |
| Singleton | A resource of which there is exactly one per context. No collection ID needed. |
| Nesting | Placing a resource under another in the URL path: /users/42/orders. |
| Controller resource | A URL that acts as a verb (e.g., /orders/99/cancel). A pragmatic REST compromise. |
| State transition | Moving a resource through its lifecycle via PATCH (e.g., status: published). |
| Flat URL | Direct access to a resource without parent context: /orders/99. |
| Query string | Key-value pairs after ? in a URL. Used for filtering, sorting, paginating. |
| Reify | Turn an abstract action into a concrete resource. "Cancel" becomes a "cancellation" resource. |
| Batch operation | A single API request that performs multiple operations. |
| Identifier | The value that uniquely identifies a resource within its collection. Usually UUID or integer. |
| Slug | A human-readable identifier: /posts/how-rest-works instead of /posts/42. |
10. Practice Problems
Problem 1 — Failure Analysis:
An API has these endpoints: POST /getUserById, POST /createNewOrder, POST /deleteUser, POST /getAllOrdersForUser. A new engineer joins and wants to add caching for the read endpoints. Why is this structurally impossible with the current design? What specific REST constraint is violated and how does it cause this failure?
Problem 2 — Design From Scratch:
Design the complete URL structure for a blogging platform with: users, posts (with draft/published states), comments, tags, and the ability to follow other users. Include at least one singleton resource, one state transition endpoint, and one controller sub-resource. Justify every decision.
Problem 3 — Synthesis:
Your team is building a "Send Password Reset Email" feature. One engineer says POST /send-password-reset-email. Another says POST /password-resets. Another says PATCH /users/{id} with {"password_reset_requested": true}. Evaluate all three approaches against REST constraints. Which is best and why?
Problem 4 — Go Deeper:
Design the URL structure for a multi-tenant SaaS application where each company (tenant) has users, projects, and tasks. The constraint: a user belongs to exactly one company, a project belongs to exactly one company, but a task can be assigned to any user in the company regardless of which project it belongs to. Show both nested and flat URL options and justify which you'd ship.
Problem 5 — Connect to Previous:
From Chapter 2, you know GET must be safe and idempotent to support caching (a REST constraint). From this chapter, you know URLs should be nouns. A teammate proposes GET /orders/export-csv to download a CSV of all orders. Identify two problems with this URL design and rewrite it correctly, explaining how your solution preserves cacheability and the uniform interface constraint.
What's Coming Next
You now know what to call your endpoints. Chapter 4 is about what goes inside them — the shape of request bodies, the structure of response JSON, how to handle nulls and missing fields, envelope vs. no-envelope patterns, and how to use status codes precisely so clients can handle errors without parsing error messages.
The URL is the address. The body is the letter inside.