9 min read

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.

code
❌ 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.

code
✅ 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)
usergetUser
ordercreateOrder
paymentprocessPayment
sessionlogin
productsearchProducts
invoicesendInvoice

Some things that feel like verbs can be modeled as resources:

ActionResource model
Cancel an orderPOST /orders/99/cancel → creates a "cancellation" event
Send an invoicePOST /invoices/12/deliveries → creates a "delivery" resource
Follow a userPUT /users/42/followers/me → creates a "follower" relationship
LoginPOST /sessions → creates a "session" resource
Publish a postPATCH /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

code
✅ /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

code
✅ /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

code
✅ 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

code
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

code
✅ 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

code
✅ /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.

code
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.

code
/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.

code
/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.

code
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

code
✅ /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)

code
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

code
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)

code
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:

code
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.

code
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

code
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

code
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

code
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:

code
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:

code
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:

code
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

TermDefinition
ResourceAny identifiable thing exposed by an API. Modeled as a noun, not a verb.
CollectionA set of resources of the same type. /users is a collection.
SingletonA resource of which there is exactly one per context. No collection ID needed.
NestingPlacing a resource under another in the URL path: /users/42/orders.
Controller resourceA URL that acts as a verb (e.g., /orders/99/cancel). A pragmatic REST compromise.
State transitionMoving a resource through its lifecycle via PATCH (e.g., status: published).
Flat URLDirect access to a resource without parent context: /orders/99.
Query stringKey-value pairs after ? in a URL. Used for filtering, sorting, paginating.
ReifyTurn an abstract action into a concrete resource. "Cancel" becomes a "cancellation" resource.
Batch operationA single API request that performs multiple operations.
IdentifierThe value that uniquely identifies a resource within its collection. Usually UUID or integer.
SlugA 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.