Resource Design Patterns · Lesson 02
Standard methods (the CRUD contract)
Five operations — List, Get, Create, Update, Delete — cover the vast majority of what any API needs to do. Knowing their exact HTTP mapping, response shapes, and status codes lets you build and consume APIs predictably, without reading the docs for each one.
By the end you'll be able to
- Map each standard method to its correct HTTP verb, URL, and status code.
- Implement full request/response JSON for all five methods on the Tasks API.
- Explain the difference between PUT (full replace) and PATCH (partial update), and when to use each.
Why standard methods matter
A custom API endpoint is a contract that every consumer must learn individually. Standard methods are the opposite: they are a shared vocabulary. If a developer knows that POST /v1/projects creates a project and returns 201 with the created resource, they can predict that POST /v1/projects/{project}/tasks creates a task, returns 201, and has the same shape. This predictability compounds: the more consistently you apply standard methods, the less documentation your clients need, and the more you can generate — SDKs, mock servers, test suites — from the schema alone.
Google's API design guide formalizes five standard methods, mirrored across its entire product portfolio. We will implement all five for the Tasks API tasks resource. The running JSON examples are real — you could wire them to an in-memory store and have a working mock server by the end of this lesson.
1. List — GET on the collection
List retrieves a page of resources from a collection. It always targets the collection URL, always uses GET, and always returns 200. The response wraps the resources in a named array (using the resource type's name as the key) alongside pagination metadata. Never return a bare JSON array at the top level — a wrapper object lets you add fields like next_page_token and total_size without a breaking change.
## Request
GET /v1/projects/proj_abc123/tasks?page_size=2&page_token=eyJvZmZzZXQiOjJ9 HTTP/1.1
Authorization: Bearer <token>
Accept: application/json
## Response — 200 OK
{
"tasks": [
{
"name": "projects/proj_abc123/tasks/task_78kz9",
"title": "Implement rate limiting",
"status": "IN_PROGRESS",
"assignee": "users/usr_riya01",
"labels": ["backend", "reliability"],
"completed": false,
"create_time": "2026-06-01T09:30:00Z",
"update_time": "2026-06-18T14:22:00Z"
},
{
"name": "projects/proj_abc123/tasks/task_99ab1",
"title": "Write API documentation",
"status": "OPEN",
"assignee": null,
"labels": ["docs"],
"completed": false,
"create_time": "2026-06-10T11:00:00Z",
"update_time": "2026-06-10T11:00:00Z"
}
],
"next_page_token": "eyJvZmZzZXQiOjR9", // absent if this is the last page
"total_size": 14 // optional — total count across all pages
}
Key pagination rules: the client passes the token it received as next_page_token back in the next request as page_token. When the response has no next_page_token (or it is empty), the collection is exhausted. The token is opaque to the client — it may encode an offset, a cursor, or a keyset; the client must not parse or construct it. page_size is a hint, not a guarantee — the server may return fewer results. See idempotency for why page tokens should be stable across repeated fetches of the same page.
Filtering and ordering are expressed as query parameters:
# Filter to open tasks assigned to a user, ordered by due date
GET /v1/projects/proj_abc123/tasks
?filter=status%3D%22OPEN%22+AND+assignee%3D%22users%2Fusr_riya01%22
&order_by=due_time+asc
&page_size=25
2. Get — GET on the resource
Get fetches a single known resource by its full resource name. It is the simplest method: no request body, one resource in the response, 200 on success, 404 if the resource doesn't exist. The response is the canonical, complete representation of the resource — the same shape returned by Create and Update.
## Request
GET /v1/projects/proj_abc123/tasks/task_78kz9 HTTP/1.1
Authorization: Bearer <token>
## Response — 200 OK
{
"name": "projects/proj_abc123/tasks/task_78kz9",
"title": "Implement rate limiting",
"notes": "Use a sliding-window counter in Redis. See ADR-012.",
"status": "IN_PROGRESS",
"due_time": "2026-07-15T17:00:00Z",
"assignee": "users/usr_riya01",
"labels": ["backend", "reliability"],
"completed": false,
"create_time": "2026-06-01T09:30:00Z",
"update_time": "2026-06-18T14:22:00Z"
}
## If the task does not exist:
## 404 Not Found
{
"error": {
"code": 404,
"status": "NOT_FOUND",
"message": "Task 'projects/proj_abc123/tasks/task_99999' not found."
}
}
3. Create — POST to the collection
Create adds a new resource to a collection. The client POSTs a partial resource representation (no name, no server-set fields) to the collection URL. The server assigns the id, sets create_time and update_time, and returns the full created resource with 201 Created and a Location header pointing to the new resource's URL.
## Request
POST /v1/projects/proj_abc123/tasks HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "Set up CI pipeline",
"notes": "Use GitHub Actions. Add lint, test, and build stages.",
"status": "OPEN",
"due_time": "2026-08-01T17:00:00Z",
"assignee": "users/usr_riya01",
"labels": ["devops", "backend"]
}
## Response — 201 Created
Location: /v1/projects/proj_abc123/tasks/task_cc451
Content-Type: application/json
{
"name": "projects/proj_abc123/tasks/task_cc451", // server-assigned
"title": "Set up CI pipeline",
"notes": "Use GitHub Actions. Add lint, test, and build stages.",
"status": "OPEN",
"due_time": "2026-08-01T17:00:00Z",
"assignee": "users/usr_riya01",
"labels": ["devops", "backend"],
"completed": false, // server default
"create_time": "2026-06-20T09:00:00Z", // server-set
"update_time": "2026-06-20T09:00:00Z" // server-set; equals create_time on creation
}
Consistency rule: the shape returned by Create is identical to Get. A client can use the body of a 201 response directly as its local copy of the resource — it should not need to immediately GET the resource to know its full state.
Client-specified ids and idempotency
Some APIs let the caller specify the id at creation time. This is useful for idempotent creates: if the client retries a failed request, it won't create a duplicate. The convention is to pass the desired id as a query parameter (AIP-133):
# Client-specified id — if "task_ci_001" already exists, return the existing resource
POST /v1/projects/proj_abc123/tasks?task_id=task_ci_001 HTTP/1.1
Idempotency-Key: req_k5m9p2qx # alternatively, an idempotency key header
{ ... same body ... }
# Server behaviour: if task_ci_001 already exists with identical body → 200 (or 201 with same id)
# If task_ci_001 exists with DIFFERENT body → 409 Conflict
For a deeper treatment of idempotency keys and retry-safe creation, see Lesson rel-02.
4. Update — PATCH (partial) or PUT (full replace)
Update modifies an existing resource. Two HTTP methods are available; they have different semantics:
| Method | Semantics | When to use |
|---|---|---|
| PATCH | Partial update — only the fields present in the request body are changed. Absent fields remain unchanged. | The default choice. Clients only send what they want to change. Safe for concurrent edits where two clients update different fields of the same resource. |
| PUT | Full replace — the entire resource is replaced with the request body. Fields absent from the body are set to defaults or removed. | Use when the semantics are truly "replace the whole thing" — e.g., uploading a configuration file, replacing a scheduled job spec. Rare in practice; PATCH is almost always better. |
For field-level control over which fields a PATCH modifies — important when a resource has many fields and you want explicit semantics — see the forward-link to field masks (rdp-04).
## PATCH request — update only status and labels
PATCH /v1/projects/proj_abc123/tasks/task_78kz9 HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
{
"status": "DONE",
"labels": ["backend", "reliability", "shipped"]
}
// "title", "notes", "assignee", "due_time" etc. are NOT in the body — they are NOT changed.
## Response — 200 OK
{
"name": "projects/proj_abc123/tasks/task_78kz9",
"title": "Implement rate limiting", // unchanged
"notes": "Use a sliding-window counter in Redis. See ADR-012.",
"status": "DONE", // updated
"due_time": "2026-07-15T17:00:00Z", // unchanged
"assignee": "users/usr_riya01", // unchanged
"labels": ["backend", "reliability", "shipped"], // updated
"completed": false,
"create_time": "2026-06-01T09:30:00Z", // unchanged — never changes
"update_time": "2026-06-20T10:15:00Z" // updated by server
}
Notice: the response is the full updated resource — not just an acknowledgement. The client never needs to GET after a PATCH to see the current state. update_time is always set by the server, never accepted from the client.
5. Delete — DELETE on the resource
Delete removes a resource. On success, it returns 204 No Content with an empty body. There is nothing to return — the resource is gone. If the resource was already deleted, return 404 (idempotent deletes — where a re-delete also returns 204 — are a valid alternative design; pick one and be consistent).
For resources that need to be recoverable, see the forward-link to soft delete (rdp-07), where Delete sets a delete_time field and moves the resource to a deleted state rather than removing it from the data store.
## Request
DELETE /v1/projects/proj_abc123/tasks/task_78kz9 HTTP/1.1
Authorization: Bearer <token>
## Response — 204 No Content
## (empty body — no JSON)
## If already deleted or never existed:
## 404 Not Found
{
"error": {
"code": 404,
"status": "NOT_FOUND",
"message": "Task 'projects/proj_abc123/tasks/task_78kz9' not found."
}
}
Status codes reference table
These are the non-negotiable conventions. Diverging from them will confuse every client that treats HTTP status codes as semantically meaningful (which all well-written clients should).
| Method | Success code | Common error codes |
|---|---|---|
| List | 200 OK | 400 (bad filter/params), 401, 403 |
| Get | 200 OK | 401, 403, 404 (not found) |
| Create | 201 Created | 400 (invalid body), 401, 403, 409 (conflict on client-specified id) |
| Update (PATCH) | 200 OK | 400 (invalid fields), 401, 403, 404, 409 (optimistic locking conflict) |
| Update (PUT) | 200 OK | 400, 401, 403, 404 |
| Delete | 204 No Content | 401, 403, 404 |
The consistency rule: one resource shape everywhere
This is the rule that makes standard methods work as a contract: every method that returns a resource returns the same shape. Get, Create, and Update for a task all return the same JSON object. List returns an array of that same object. No method returns a stripped-down "summary" shape that is different from the "full" shape returned by Get.
Violating this rule — common when someone adds a "lightweight" List response to reduce payload size — creates a class of subtle client bugs where a cached value from a List is missing fields that only appear in Get. If response size is genuinely a problem, use field masks (rdp-04) to let callers request a subset, rather than baking a second shape into the API contract.
"What HTTP status code does Create return?" — 201, not 200. This trips up candidates who default to "200 for success." The full answer: 201 with a Location header and the created resource in the body. Similarly, "what does Delete return?" — 204, not 200. Knowing the correct status codes for all five methods is a signal that you've implemented real APIs, not just read about them.
Under the hood: how PATCH differs from PUT at the protocol and storage layer
PATCH and PUT look similar at the wire level — both are HTTP methods targeting a resource URL with a JSON body. The difference is in semantics and what the server does with that body.
With PUT, the server treats the body as the new complete state of the resource. Fields not in the body are reset to defaults. The storage implementation can be a simple UPDATE table SET col1=?, col2=? ... WHERE id=? for every column — no selective merging required. The client is responsible for sending all fields, including ones it didn't intend to change.
With PATCH, the server merges the body into the existing resource. Only the keys present in the body are updated; absent keys are left as-is. This requires the server to:
- Read the current resource state (SELECT by id).
- Merge: for each key in the PATCH body, overwrite the current value.
- Write the merged state back (UPDATE with only changed columns).
This read-modify-write cycle introduces a concurrency window. Two clients can both read the resource at time T, each PATCH a different field, and write back — the second write wins for all fields, losing the first client's other fields if a naive full-row update is used. Production servers handle this with optimistic locking: they include an etag (a hash of the resource state) in responses, and require the client to pass it back in an If-Match header on the PATCH. If the etag has changed between the read and the write, the server returns 409 Conflict instead of silently clobbering the concurrent edit.
For primitive fields (strings, booleans, numbers), PATCH semantics are unambiguous: present = update, absent = leave alone. Arrays are where it gets subtle: does a PATCH body with "labels": ["shipped"] replace the whole labels array or append to it? The standard interpretation (and what most clients expect) is replace: the body represents the desired new value of the field, not a delta. If you need append/remove semantics for arrays, that belongs in a custom method (rdp-03), not in PATCH.
Returning different resource shapes from different methods. It is tempting to return a "lean" task from List (just id and title) and a "full" task from Get. Don't. Clients cache resources, and a List response that populates the cache with a partial object will cause subtle, hard-to-debug errors the next time the client tries to use a cached field that List omitted. Use the same shape everywhere; add field masks (rdp-04) if you genuinely need to reduce payload.
Do return the full updated resource in the Update response body. Don't return 204 No Content after a PATCH. A 204 forces clients to immediately GET the resource again to find out what update_time was set to or whether any server-side defaults were applied. The 200 + full resource is one round trip instead of two, and it is what clients expect from a standard method.
How to debug & inspect standard methods
Most standard method bugs fall into one of four categories: wrong status code, wrong response shape, missing Location header on Create, or silent data loss on PATCH. Here is how to catch each one.
| Symptom | Likely cause | Fix |
|---|---|---|
| Create returns 200 instead of 201 | Handler uses a generic "success" status code | Explicitly set HTTP 201 and include the Location header |
| PATCH wipes fields not in the body | Handler is doing a PUT-style full replace instead of a merge | Implement read-modify-write: fetch current state, apply only provided keys, write back |
Delete returns 200 with {"ok":true} | Generic response wrapper applied to all methods including Delete | Return 204 with an empty body; strip the response wrapper from Delete handlers |
List returns a bare array [...] | Missing wrapper object | Wrap in {"tasks": [...], "next_page_token": "..."} |
Create returns 201 but body is missing create_time | Server returned the input body unchanged instead of the full created resource | Fetch (or construct) the full resource after inserting and return it in the 201 response |
🧠 Quick check
1. You call POST /v1/projects/proj_abc123/tasks and receive a 200 OK with the task body. What is wrong?
201 signals that a new resource was created, while 200 signals a successful read or update of an existing one. Clients use this distinction: some HTTP caches treat 201 responses differently, and monitoring systems use it to count creation events vs read events. The Location header is equally important — it tells the client the canonical URL of the new resource.
2. A client PATCHes a task with {"status": "DONE"}. The response shows "title": null. What went wrong?
PATCH means partial update: only provided fields change. If the server does a full replace on a PATCH request, clients must send every field on every update — defeating the purpose of PATCH. The fix is to read the current resource state, overwrite only the keys present in the PATCH body, and write back the merged result.
3. Which HTTP method should be used to replace an entire task resource, discarding any fields not included in the request body?
PUT means "replace the resource with exactly this body." It is appropriate when the client owns the full state and wants to enforce it. PATCH is for partial updates where the server should merge the changes. In practice, PATCH is correct for most "edit a resource" use cases; PUT is reserved for true replace scenarios like uploading a configuration.
4. The List response for tasks should look like:
The wrapper object is essential for evolvability. A bare array can never have pagination metadata added without a breaking change. The wrapper using the resource type's name as the key ("tasks") is the standard convention — it makes the response self-describing and consistent with how single-resource responses name their content.
5. After a successful Delete, the client immediately calls Get on the same resource name. What should the server return?
404 is correct for a hard-deleted resource. The resource name no longer resolves to anything. If your API uses soft delete (the resource is marked deleted but not removed from storage), Get might return 200 with a delete_time field instead — but that requires the API to document this behaviour explicitly.
✍️ Build exercise: implement the five standard methods for a mock Tasks API
Using any language or framework (Node/Express, Python/Flask, Go net/http), implement an in-memory Tasks API with the five standard methods. Requirements:
POST /v1/projects/:project/tasks— server-assigns id, returns 201 + Location + full resource.GET /v1/projects/:project/tasks— returns paginated list; supportpage_sizequery param; returnnext_page_tokenif more pages exist.GET /v1/projects/:project/tasks/:task— returns 200 or 404.PATCH /v1/projects/:project/tasks/:task— partial update; returns 200 with full resource; verify parent task belongs to the stated project.DELETE /v1/projects/:project/tasks/:task— returns 204 empty body.
The Get, Create, Update, and List responses must all use the identical task JSON shape.
Model answer (Node/Express skeleton):
// In-memory store
const tasks = {}; // key: "proj_id/task_id"
const randomId = () => Math.random().toString(36).slice(2, 9);
// POST /v1/projects/:project/tasks
app.post('/v1/projects/:project/tasks', (req, res) => {
const id = `task_${randomId()}`;
const now = new Date().toISOString();
const task = {
name: `projects/${req.params.project}/tasks/${id}`,
...req.body,
completed: req.body.completed ?? false,
create_time: now,
update_time: now,
};
tasks[task.name] = task;
res.status(201)
.header('Location', `/v1/projects/${req.params.project}/tasks/${id}`)
.json(task);
});
// PATCH /v1/projects/:project/tasks/:task
app.patch('/v1/projects/:project/tasks/:task', (req, res) => {
const key = `projects/${req.params.project}/tasks/${req.params.task}`;
if (!tasks[key]) return res.status(404).json({ error: { code: 404, message: 'Not found' } });
tasks[key] = { ...tasks[key], ...req.body, update_time: new Date().toISOString() };
res.json(tasks[key]);
});
Rubric: Full marks for correct status codes (201/200/204/404), a wrapper object on List with pagination, identical resource shapes across all methods, and PATCH performing a merge (not replace). Deduct marks if Delete returns 200 with a body, or if Create returns 200, or if PATCH wipes unmentioned fields.
Key takeaways
- Five standard methods cover most API needs: List, Get, Create, Update, Delete — mapped to GET (collection), GET (resource), POST (collection), PATCH/PUT (resource), DELETE (resource).
- Status codes are fixed: 201 for Create, 200 for List/Get/Update, 204 for Delete. Deviating from these breaks client expectations.
- PATCH = merge (partial update); PUT = replace (full replace). PATCH requires a read-modify-write cycle. Use PATCH by default; reserve PUT for explicit full-replace semantics.
- One resource shape everywhere: Get, Create, Update, and each item in a List response must all return the identical JSON structure.
- Create returns the full created resource in the body — clients should not need a follow-up GET to learn the resource's server-assigned fields.
- For retry-safe creation, use client-specified ids or an idempotency key header (see rel-02).
Sources & further reading
- Google AIP-131 — Standard methods: Get
- Google AIP-132 — Standard methods: List
- Google AIP-133 — Standard methods: Create
- Google AIP-134 — Standard methods: Update
- Google AIP-135 — Standard methods: Delete
- MDN — HTTP PATCH method
- MDN — 201 Created
- RFC 5789 — PATCH Method for HTTP
- Stripe API — Pagination