Skip to main content

Resources and URL Design: Nouns, Plurals, Hierarchy

What This Concept Is

A resource is the noun the API exposes. A URL names an individual resource or a collection of resources. REST is built on the idea that the URL points at a thing (not an action), and the HTTP verb decides what to do with the thing.

Baseline rules that most large APIs converge on:

  • resources are nouns, not verbs: /orders, not /createOrder
  • collections are plural: /users, not /user
  • individual items are addressed by identifier: /users/u_123
  • hierarchy reflects containment: /projects/p_1/reports/r_99 means "report r_99 in project p_1"
  • URLs are stable - identifiers do not change when fields change
  • URL segments are lowercase-kebab-case or lowercase; never mixedCase

Resources can be top-level or nested. Nested resources imply that the child cannot exist without the parent. Cross-cutting resources stay top-level.

Why It Matters Here

URL structure is the first thing a consumer sees and the hardest thing to change. A URL leaks through logs, bookmarks, CDNs, OpenAPI specs, client SDKs, and routing tables. A poorly chosen hierarchy propagates for years.

Resource modeling is also where most failure modes live. Endpoints that feel natural from the implementation side (/saveUser, /processOrderBatch2) read as noise from the consumer side.

Concrete Example

A project-management API modeled two ways.

Bad (actions-as-endpoints):

POST /createProject
POST /addMemberToProject
POST /listReportsOfProject
POST /deleteProjectReport
GET /project?id=p_1

Good (resources + verbs):

GET    /projects                            # list
POST /projects # create
GET /projects/{projectId} # read
PATCH /projects/{projectId} # partial update
DELETE /projects/{projectId} # delete

GET /projects/{projectId}/members # list members (scoped)
POST /projects/{projectId}/members # add a member
DELETE /projects/{projectId}/members/{userId}

GET /projects/{projectId}/reports
POST /projects/{projectId}/reports
GET /projects/{projectId}/reports/{reportId}

Notice:

  • verbs disappear from URLs; they live in HTTP methods
  • collection / item distinction is uniform: /X for list and create, /X/{id} for read/update/delete
  • hierarchy only where a child logically belongs to a parent
  • projectId is passed positionally, not in query string

When to flatten. Hierarchies deeper than two levels start to hurt. Instead of /orgs/{o}/projects/{p}/reports/{r}/comments/{c}, prefer a top-level comments resource with a report_id reference:

GET /comments?report_id=r_99
GET /comments/{commentId}

Geewax is explicit: resources are entities, and deep hierarchies are an anti-pattern because they prevent reuse (you cannot easily list comments across reports, for example).

A full resource representation example:

{
"id": "proj_01HXABC",
"name": "Q4 Planning",
"created_at": "2026-11-01T10:00:00Z",
"owner": { "id": "u_17", "href": "/users/u_17" },
"status": "active"
}

href lets clients navigate without reassembling URLs themselves. That is HATEOAS in its most useful, minimal form.

Common Confusion / Misconception

"I need a new URL for this action." Usually you need a new verb, a new field, or a custom method (Cluster 3). A new URL per action ends up as /doThing, /doThingFast, /doThingFastV2.

"Nested resources are always better than flat." They are worse when the child has a meaningful cross-parent existence. A report belonging to a project is naturally nested. A comment that can reference many parent types should be flat.

"Resources must map 1:1 to database tables." No. Resources should match the consumer's mental model. A single resource may span tables; a single table may be invisible to consumers. The contract is decoupled from storage.

"GET with a body is fine for complex queries." It is not - many proxies, CDNs, and HTTP libraries strip or ignore bodies on GET. For a read with a complex filter, use query parameters or a POST /things:search custom method (Cluster 3).

How To Use It

When modeling a new API:

  1. List the nouns in the domain (often one-to-one with DDD aggregates).
  2. Pick which are top-level resources and which are children.
  3. Draw the hierarchy; if any branch is deeper than two levels, flatten it.
  4. Name collections plural, items by ID, everything lowercase.
  5. For each resource, design the five standard methods before any custom action (Cluster 2's next concept).
  6. Only after that, look at what does not fit and consider custom methods (Cluster 3).

Write the full URL list on one page before writing any request/response schemas.

Check Yourself

  1. Is /users/{id}/avatar a sub-resource or a field? How would you decide?
  2. Your API has /reports/{rid}/comments/{cid} and /tasks/{tid}/comments/{cid}. What is the cost to consumers who want "all comments I authored"? How would you fix it?
  3. A teammate proposes /api/getOrdersByUser/{uid}. Rewrite it as a resource URL and explain the change.

Mini Drill or Application

Design the URL surface for this domain: an online library with patron, book, copy, loan, reservation, and fine. Produce:

  1. the list of top-level resources with justification
  2. the hierarchy where sensible, flattened otherwise
  3. the full URL list (collections and items) for the five standard methods per resource
  4. a one-paragraph note on any deliberate deviations (e.g., why /copies is top-level even though copies belong to books)

Constrain to one page. If it does not fit, the model is too deep.

Read This Only If Stuck