Resource Design Patterns · Lesson 01
Resource-oriented design & naming
Before writing a single endpoint, you need a resource model: a map of the domain's nouns, how they nest, and how they are named. Get this right and every method follows naturally. Get it wrong and you spend years papering over structural debt.
By the end you'll be able to
- Identify the resources, collections, and hierarchy in an unfamiliar domain.
- Construct correct full resource names in the
parent/id/child/idform used by production APIs. - Decide whether something belongs as a resource, a field, or a custom action — and explain why.
The core mindset: everything is a named resource
Resource-oriented design (sometimes called resource-oriented architecture) starts with a simple premise: your API exposes things (resources), not actions (verbs). A task is a thing. The list of tasks inside a project is a thing — a collection. The standard CRUD operations are then applied uniformly to those things. This is the opposite of an RPC-style API that exposes createTask, archiveTask, listTasksByProject as separate procedures.
Think of it like a filing cabinet. Each drawer is a collection. Each folder inside is a resource. You don't design a special "open drawer, rifle through folders, pull out the red one" operation — you just identify the folder by its location and use a standard verb (read it, replace it, delete it). The uniform interface is the whole point.
Google's internal platform guidelines, published as the API Improvement Proposals (AIP-121), formalize this: every API is a collection of resources. Resources are identified by a resource name. Standard methods operate on those names. This module builds the vocabulary you need to apply that model in practice.
Collections and resources: the two building blocks
A collection is a container of resources of the same type. By convention, collection names are plural lowercase nouns: projects, tasks, labels. A resource is a single item inside a collection, identified by its id segment. Together they form a two-level pattern that can nest arbitrarily:
Resource names: the full path is the identity
In REST, a URL is just an address. In resource-oriented design, the full URL path segment after the version prefix is the resource name: a stable, globally unique string that identifies the resource, not just its local id. The format is a slash-separated alternation of collection names and resource ids:
# Pattern: /v1/{collection}/{resource-id}/{child-collection}/{child-id}
/v1/projects/proj_abc123 # resource name for a single project
/v1/projects/proj_abc123/tasks # collection of tasks inside that project
/v1/projects/proj_abc123/tasks/task_78 # resource name for a single task
# The id segments ("proj_abc123", "task_78") are server-assigned.
# The collection names ("projects", "tasks") are fixed parts of the API contract.
# The entire path — not just the id — is the resource name.
Why does the full path matter as an identity? Because task_78 alone is ambiguous — you'd need to know its parent to look it up. The resource name projects/proj_abc123/tasks/task_78 is self-contained. You can store it in a log, a foreign key, or an email notification, and any service that receives it knows exactly what it refers to and how to fetch it. AIP-122 formalizes this property.
Server-assigned ids and stability
Ids are assigned by the server, not chosen by the caller (with one exception — client-specified ids covered in the next lesson). They should be:
- Opaque. Don't encode business logic in the id — no sequential integers that reveal your record count, no ids that embed the parent (no
proj_abc-task_78) since that ties the id to the hierarchy. - Stable. Once assigned, an id never changes. Resource names are bookmarkable, loggable, and used as foreign keys across systems.
- URL-safe. Stick to alphanumeric characters, hyphens, and underscores. Avoid slashes or characters requiring percent-encoding.
A common format is a short prefix that identifies the resource type plus a random suffix: proj_abc123, task_78kz9. The prefix helps humans and tooling quickly identify what type of resource a bare id refers to — Stripe pioneered this style and it has become widely imitated.
Building the Tasks API resource model
Let's design the full resource hierarchy for a Tasks API that supports projects, tasks, and labels. Start by listing the domain nouns and their ownership relationships before writing a single URL.
A team productivity tool needs an API. Users organize work into projects. Each project contains tasks. Tasks can have a set of string labels (e.g., "urgent", "backend") and can be assigned to a user. There is no concept of tasks existing outside a project — every task has exactly one parent project.
Working through the relationships:
- Projects are top-level resources. They exist independently and contain tasks. →
/v1/projectscollection,/v1/projects/{project}resource. - Tasks are always children of a project. A task only exists in the context of its project. →
/v1/projects/{project}/taskscollection,/v1/projects/{project}/tasks/{task}resource. - Labels are a set of strings on a task — they have no identity, no lifecycle, and no standard CRUD of their own. → A
labels[]field on the task resource, not a sub-collection. To add a label you PATCH the task; you don't POST to/tasks/{task}/labels.
Here is the complete URL layout that falls out of this model:
## Tasks API — URL structure
# Projects
GET /v1/projects # list all projects
POST /v1/projects # create a project
GET /v1/projects/{project} # get one project
PATCH /v1/projects/{project} # update a project
DELETE /v1/projects/{project} # delete a project
# Tasks (always scoped under a project)
GET /v1/projects/{project}/tasks # list tasks in a project
POST /v1/projects/{project}/tasks # create a task in a project
GET /v1/projects/{project}/tasks/{task} # get one task
PATCH /v1/projects/{project}/tasks/{task} # update a task
DELETE /v1/projects/{project}/tasks/{task} # delete a task
# Labels are NOT a sub-resource — modify via PATCH on the task:
# PATCH /v1/projects/{project}/tasks/{task} { "labels": ["urgent", "backend"] }
What belongs as a resource, a field, or a custom action?
This is the judgment call that separates a well-designed API from a messy one. The heuristic:
| It belongs as a… | When… | Example in Tasks API |
|---|---|---|
| Resource | It has its own identity (a stable id), its own lifecycle (create/update/delete independent of its parent), and callers need to address it directly. | A task — it has an id, can be updated independently, and callers reference it by name in other contexts (notifications, comments). |
| Field | It is a property of a resource — no independent identity, lives and dies with its parent, always accessed through the parent. | labels, assignee, status on a task. You update them by updating the task. |
| Custom action (rdp-03) | The operation doesn't map cleanly to Create/Read/Update/Delete — it is a state machine transition, a side-effectful command, or an operation that crosses resource boundaries. | POST /tasks/{task}:complete — marking a task done triggers side effects (sends notifications, updates a counter) that go beyond a simple field update. |
Conflating "it has multiple values" with "it needs its own sub-collection." A task can have many labels. That doesn't mean /tasks/{task}/labels is right. Ask: do callers need to GET/DELETE an individual label by id? Does a label have fields beyond its string value? If the answer is no, it's a field, not a resource. Adding a sub-collection for labels means every consumer must learn an extra layer of HTTP operations and pagination for something that is functionally a string array.
Worked example: the full task resource shape
A task resource in JSON. Notice how every field name uses snake_case (consistent with most REST APIs and the AIP style guide), timestamps use RFC 3339, and the resource's own full resource name is included as a name field — making it self-identifying in log output, webhooks, and responses that embed tasks from multiple projects.
// GET /v1/projects/proj_abc123/tasks/task_78kz9
// 200 OK
{
"name": "projects/proj_abc123/tasks/task_78kz9", // full resource name
"title": "Implement rate limiting",
"notes": "Use a sliding-window counter in Redis. See ADR-012.",
"status": "IN_PROGRESS", // enum: OPEN | IN_PROGRESS | DONE
"due_time": "2026-07-15T17:00:00Z", // RFC 3339, UTC
"assignee": "users/usr_riya01", // resource name, not raw id
"labels": ["backend", "reliability"], // field, not sub-resource
"completed": false,
"create_time": "2026-06-01T09:30:00Z",
"update_time": "2026-06-18T14:22:00Z"
}
A few design choices to call out:
assigneeis stored as a resource name (users/usr_riya01), not just an id string. This means any service that receives a task can resolve the assignee without knowing the user API's URL structure separately.- Timestamp fields end in
_timeand are always UTC RFC 3339. Using a consistent suffix and format means tooling (logs, dashboards) can generically parse them without per-field knowledge. statusis an enum, not a boolean. A booleanis_donecan't represent "blocked" or "in review" later. Design for the enum from day one — it's backwards-compatible to add values; removing them isn't.
Under the hood: how resource names are resolved by a server
When a request arrives at GET /v1/projects/proj_abc123/tasks/task_78kz9, the server does more than look up a row by id. The resource name encoding carries authority information used to route, authorize, and fetch the resource.
- Parse the path into segments. The router splits on
/and matches the pattern/v1/{collection}/{coll_id}/{child_collection}/{child_id}. This gives it: collection=projects, parent_id=proj_abc123, child_collection=tasks, child_id=task_78kz9. - Authorize against the parent. The server first confirms the authenticated caller has
projects.getorprojects.tasks.listpermission onproj_abc123. Resource-oriented authorization is hierarchical — access to a parent implies (or constrains) access to its children. - Validate the child belongs to the parent. The storage layer fetches
task_78kz9and checks that itsproject_idcolumn equalsproj_abc123. This prevents horizontal privilege escalation: a caller who knows a task id from a different project cannot access it via their own project's URL. - Return the resource with its own full name. The response body's
namefield echoes back the full resource name. Clients cache this and use it directly in future requests or as a foreign key.
This four-step pattern is why the full path matters: it makes step 3 possible. If tasks had top-level URLs (/v1/tasks/{task}), the server would need separate logic to verify ownership on every request. Nesting pushes that verification into the router itself.
"Should tasks be nested under projects or top-level?" is a real system design question. The short answer: nest them if tasks always require a project (ownership is mandatory), expose them at the top level as well if callers frequently need to query across all projects (e.g. "all tasks assigned to me"). Many production APIs do both — nested for creation and standard CRUD, a separate top-level GET /v1/tasks?assignee=me for cross-project queries. See the case study framework for how to structure this trade-off in a design interview.
Do include the full resource name as a name field in every resource response. Don't only return the bare id. When a task appears in a webhook payload, a log entry, or a search result, the consumer needs the full resource name to act on it — they shouldn't have to reconstruct it from context fields. This is a one-line addition that eliminates an entire class of client bugs.
Naming conventions: the concrete rules
These are not aesthetic preferences — inconsistent naming is a breaking change once clients start pattern-matching on field names.
| What | Convention | Example |
|---|---|---|
| Collection names in URL | Plural, lowercase, no hyphens | /projects, /tasks |
| Resource ids in URL | Opaque, URL-safe alphanumeric, optional type prefix | proj_abc123, task_78kz9 |
| JSON field names | snake_case | create_time, due_time, assignee |
| Enum values | SCREAMING_SNAKE_CASE | IN_PROGRESS, DONE |
| Timestamp fields | Suffix _time; RFC 3339 UTC string | create_time, due_time |
| Boolean fields | Positive assertion (avoid double-negatives) | completed not not_incomplete |
| Resource name field | Always "name" at the top level of the resource | "name": "projects/proj_abc123/tasks/task_78kz9" |
🧠 Quick check
1. A task has an assignee_id and a list of comment_ids. Comments have their own body text, author, and timestamp. How should comments be modelled?
Comments have their own stable id, their own fields (author, body, timestamp), and callers will want to create/delete individual comments independently of updating the entire task. They belong as a sub-collection. Embedding them as a field would bloat the task payload and make pagination of a large comment thread impossible.
2. Why is "name": "projects/proj_abc123/tasks/task_78kz9" returned in the task response body, when the URL already contains that information?
The resource name in the body is a convenience for downstream consumers. When a task appears in a Slack notification, an email digest, or as a field in another resource, the URL context is gone — the embedded name makes the resource addressable in any context.
3. A designer proposes /v1/projects/{project}/tasks/{task}/labels/{label} for adding a label to a task. What is the main problem?
A label is a tag value, not a first-class resource. It has no fields beyond its value, no individual lifecycle. Making it a sub-resource means callers must learn and implement an extra set of HTTP operations for something that is functionally a PATCH on a string array.
4. You receive the string projects/proj_xyz/tasks/task_99 in a webhook payload. What can you infer from it directly, without any additional API call?
The full resource name is self-describing: it encodes type, parent, id, and routing information. This is exactly why resource-oriented APIs include the full name in responses rather than bare ids — the name is usable as a complete reference without side-channel knowledge.
5. When should you NOT nest a resource under its parent?
Nesting is right when the child's identity requires the parent. But if callers regularly query across all parents (cross-cutting queries), a top-level list endpoint with a filter is more ergonomic. Many production APIs expose both: nested for CRUD, top-level for cross-cutting list queries.
✍️ Design exercise: model a Docs API resource hierarchy
A collaborative document editing API has the following domain objects:
- Workspaces — top-level containers owned by an organisation.
- Documents — always live inside exactly one workspace; have a title, body, and created_by field.
- Comments — attached to a specific document; have a body, author, and resolved status.
- Tags — string labels on a document (like "draft", "published"); callers never need to address an individual tag, only set/unset the whole list.
- Collaborators — users who have been granted access to a workspace; each collaborator entry has a user reference, a role (VIEWER or EDITOR), and a joined_at timestamp.
Design: (1) the full URL structure, (2) what belongs as a resource vs field, and (3) the JSON shape for a single document resource.
Model answer:
## Resource vs field decisions:
# Documents: nested resource — own identity, lifecycle, callers address them directly
# Comments: nested sub-resource — own identity (id, author, timestamps), CRUD individually
# Tags: FIELD on document — no identity, no separate CRUD, updated via PATCH
# Collaborators: nested resource — own identity (user + role), callers add/remove individually
## URL structure:
GET /v1/workspaces
POST /v1/workspaces
GET /v1/workspaces/{workspace}
PATCH /v1/workspaces/{workspace}
DELETE /v1/workspaces/{workspace}
GET /v1/workspaces/{workspace}/documents
POST /v1/workspaces/{workspace}/documents
GET /v1/workspaces/{workspace}/documents/{document}
PATCH /v1/workspaces/{workspace}/documents/{document}
DELETE /v1/workspaces/{workspace}/documents/{document}
GET /v1/workspaces/{workspace}/documents/{document}/comments
POST /v1/workspaces/{workspace}/documents/{document}/comments
PATCH /v1/workspaces/{workspace}/documents/{document}/comments/{comment}
DELETE /v1/workspaces/{workspace}/documents/{document}/comments/{comment}
GET /v1/workspaces/{workspace}/collaborators
POST /v1/workspaces/{workspace}/collaborators
PATCH /v1/workspaces/{workspace}/collaborators/{collaborator}
DELETE /v1/workspaces/{workspace}/collaborators/{collaborator}
## Document resource JSON shape:
{
"name": "workspaces/ws_abc/documents/doc_99",
"title": "API Design Guide",
"body": "...",
"created_by": "users/usr_riya01",
"tags": ["draft", "internal"], // field, not sub-resource
"create_time": "2026-05-01T10:00:00Z",
"update_time": "2026-06-15T08:45:00Z"
}
Rubric: Full marks if documents/comments/collaborators are resources (with reasoning), tags are a field (with reasoning), URL alternation is correct (collection/resource/collection/resource), and the JSON shape includes a name field with the full resource name and snake_case fields with a _time suffix on timestamps. Deduct marks for nesting tags as a sub-collection or for making collaborators a field (they have a role, a joined_at — they have their own identity).
Key takeaways
- Resources are nouns with a stable identity. Collections are plural-noun containers. They alternate in the URL path:
/collection/{id}/child-collection/{child-id}. - The full resource name (the entire URL path segment) is the identity, not just the id. Include it as a
namefield in every resource response. - Resource ids are server-assigned, opaque, and stable. A type prefix (
proj_,task_) aids human readability without encoding business logic. - Ask three questions to decide resource vs field: Does it need its own id? Does it have an independent lifecycle? Do callers address it directly? Three "yes" answers → resource. Any "no" → probably a field.
- Labels, tags, and string sets are almost always fields. Sub-collections are for objects that need full CRUD and have their own fields.
- Use consistent naming conventions: plural collection names, snake_case fields,
_timesuffix on timestamps, SCREAMING_SNAKE_CASE enums.