C.W.K.
Stream
Lesson 02 of 07 · published

URI Design — Nouns, Plurals, and the Rules That Save You

~10 min · rest-design, uri-design, naming

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"URIs are the smallest, most visible piece of your API contract. They show up in logs, dashboards, docs, billing reports, customer error reports. Get them right; you won't regret it. Get them wrong; you'll be living with it for years."

The Six Rules That Cover 95% of Decisions

1. Nouns, not verbs. URIs name things, not actions. /users/42 not /getUser/42. The verb is the HTTP method. GET /users/42 already says "get me user 42"; GET /getUser/42 says it twice and confuses the protocol's verb.

2. Plural collections, singular items. A collection is a set of things: /users. An item is a specific thing in that set: /users/{id}. Stick to plural for collections — /user/42 reads weird and breaks consistency.

3. Nesting reflects ownership, not relationships. /users/{id}/orders means "orders that belong to this user." If the relationship is many-to-many or doesn't imply ownership, use a top-level resource and query params: /orders?user_id={id} rather than /users/{id}/orders.

4. Lowercase, hyphens between words. /account-settings/{id} not /AccountSettings/{id} or /account_settings/{id}. URLs are case-sensitive in most contexts; lowercase removes ambiguity. Hyphens win over underscores because underscores are visually hidden under hyperlinks (try seeing account_settings versus account-settings).

5. No file extensions. /users/42 not /users/42.json. The format belongs in Content-Type, not the URI. .json in the URI couples the representation to the resource identity, which is exactly what content negotiation exists to avoid.

6. Don't nest deeper than 2-3 levels. /users/{u}/orders/{o}/items/{i} is at the edge. /users/{u}/orders/{o}/items/{i}/comments/{c}/replies/{r} is a sign you've stopped designing and started arguing with the API. Flatten by promoting deep resources to top-level when they have stable IDs (/replies/{r}).

The ID Choice — Opaque Beats Sequential

Two common ID strategies:

  • Sequential integers (/users/1, /users/2): cheap, sortable, debuggable. Leaks growth rate to anyone watching the IDs increment. Easy to enumerate (/users/1, /users/2, ...).
  • Opaque strings (/users/usr_8x3kPq, UUIDs, ULIDs, Stripe-style prefixed IDs): no enumeration, no growth rate leak, easy to grep in logs. Slightly less debuggable (harder to remember).

For anything user-facing or public, prefer opaque. Stripe's prefix convention (cus_ for customers, pay_ for payments) is gold — it makes ID confusion impossible. Internal-only IDs can stay integer.

Action URIs — The One Exception to Rule 1

When you genuinely have an operation that isn't a resource, the convention is POST /resources/{id}/actionName. POST /payments/42/refund, POST /jobs/abc/cancel. The action name appears in the URI because there's no noun to use. This is the hybrid pattern from Lesson 3.1 — coherent with the resource-oriented rest of the API.

URIs are forever. Once a URI is in production logs, customer integration code, third-party docs, browser bookmarks, and search engine indexes, changing it requires a migration that may take years. Spend an extra hour on URI design at the start; it's the cheapest hour you'll ever spend.

Bad → Good Walkthrough

Five common smells and their fixes:

BAD:  GET    /getUserById?id=42         (verb in path, query for ID)
GOOD: GET    /users/42

BAD:  POST   /createOrder               (verb in path)
GOOD: POST   /orders

BAD:  DELETE /users/42/delete           (verb redundant with method)
GOOD: DELETE /users/42

BAD:  GET    /User/42.json              (case + extension)
GOOD: GET    /users/42  (Accept: application/json)

BAD:  PATCH  /user_settings_for/42      (snake + underscore + awkward)
GOOD: PATCH  /users/42/settings

cwkPippa's URI Choices

cwkPippa's URIs follow these rules — plural collections, opaque conversation IDs (UUIDs), lowercase with hyphens, no file extensions, max 2-3 levels of nesting. The notable exception: action endpoints like POST /api/council/{id}/finalize and POST /api/heartbeat/cron/{id}/run-now, which use verb-suffixes deliberately because they're actions, not resources. That deliberate exception is fine; the bar for adding one is "is there genuinely no noun for this operation?"

Code

The vocabulary that works — six rules in action·text
# Resource-oriented URI vocabulary — copy this template

# Collections (plural)
GET    /users                                # list
POST   /users                                # create

# Items (singular within collection)
GET    /users/{id}                           # read
PUT    /users/{id}                           # replace
PATCH  /users/{id}                           # partial update
DELETE /users/{id}                           # remove

# Nested ownership (max 2-3 levels)
GET    /users/{id}/orders                    # orders belonging to user
POST   /users/{id}/orders                    # create order under user
GET    /users/{id}/orders/{oid}              # read specific order

# Sub-collections (avoid deep nesting)
GET    /orders/{oid}/items                   # OK
GET    /orders/{oid}/items/{iid}             # OK (3 deep, edge)
GET    /comments/{cid}                       # promoted, not /orders/{o}/items/{i}/comments/{c}

# Action endpoints (verb in path, POST method, named action)
POST   /payments/{id}/refund
POST   /jobs/{id}/cancel
POST   /messages/{id}/forward
FastAPI: plural collections, opaque IDs, sensible nesting, action endpoints·python
# FastAPI routes following all six rules
from fastapi import FastAPI, APIRouter, status

app = FastAPI()

# Top-level collection: /users
users = APIRouter(prefix='/users', tags=['users'])

@users.get('')
async def list_users():
    return await query_users()

@users.post('', status_code=status.HTTP_201_CREATED)
async def create_user(payload: dict):
    new_id = await create(payload)  # opaque ID, like 'usr_8x3kPq'
    return {'id': new_id, **payload}

@users.get('/{uid}')
async def read_user(uid: str):
    return await get(uid)

# Nested: /users/{uid}/orders
@users.get('/{uid}/orders')
async def list_user_orders(uid: str):
    return await orders_for_user(uid)

# Promoted (don't go deeper than 3 levels): /orders/{oid}
orders = APIRouter(prefix='/orders', tags=['orders'])

@orders.get('/{oid}')
async def read_order(oid: str):
    return await get_order(oid)

# Action endpoint — verb-name in path, POST method
@orders.post('/{oid}/cancel', status_code=status.HTTP_202_ACCEPTED)
async def cancel_order(oid: str):
    return await cancel(oid)

app.include_router(users)
app.include_router(orders)

External links

Exercise

Take this list of badly-designed URIs and rewrite each to follow the six rules: (1) POST /createUser, (2) GET /User_Profile/42.json, (3) POST /user/42/delete, (4) GET /getOrdersForUser?userId=42, (5) PUT /modify-order/abc/status?to=cancelled, (6) GET /users/42/orders/abc/items/1/comments/9/replies/3. For each, explain which rule(s) the original violated.
Hint
(1) POST /users. (2) GET /users/42 with Accept: application/json. (3) DELETE /users/42. (4) GET /users/42/orders. (5) PATCH /orders/abc with {status: 'cancelled'} or POST /orders/abc/cancel. (6) Promote: GET /replies/3 (stop nesting once you have stable IDs). Rule violations include: verbs in path (1, 3, 4, 5), case + extension (2), underscores (2), redundant verb with method (3), action-as-query-param (5), deep nesting (6).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.