API Design

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.

⏱ 18 min Difficulty: core Prereq: Resource design (rdp-01), REST (as-02)

By the end you'll be able to

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.

List GET /collection 200 OK page of resources Get GET /collection/{id} 200 OK single resource Create POST /collection 201 Created created resource Update PATCH / PUT /collection/{id} 200 OK updated resource Delete DELETE /collection/{id} 204 No Content empty body
The five standard methods and their HTTP mapping. List and Create target the collection URL. Get, Update, and Delete target the individual resource URL. The status codes are fixed conventions — not choices.

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:

MethodSemanticsWhen 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).

MethodSuccess codeCommon error codes
List200 OK400 (bad filter/params), 401, 403
Get200 OK401, 403, 404 (not found)
Create201 Created400 (invalid body), 401, 403, 409 (conflict on client-specified id)
Update (PATCH)200 OK400 (invalid fields), 401, 403, 404, 409 (optimistic locking conflict)
Update (PUT)200 OK400, 401, 403, 404
Delete204 No Content401, 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.

🎯 Interview angle

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

  1. Read the current resource state (SELECT by id).
  2. Merge: for each key in the PATCH body, overwrite the current value.
  3. 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.

# Optimistic locking on PATCH — the client echoes back the etag it received PATCH /v1/projects/proj_abc123/tasks/task_78kz9 HTTP/1.1 If-Match: "etag_a3f9b1" Content-Type: application/json { "status": "DONE" } → 200 OK (etag still matches — no concurrent modification) → 409 Conflict (someone else patched the task between your GET and PATCH)

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.

⚠️ Common trap

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 this, not that

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.

# Create a task and verify: 201, Location header, full resource in body curl -i -X POST https://api.example.com/v1/projects/proj_abc123/tasks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Test task","status":"OPEN"}' HTTP/1.1 201 Created Location: /v1/projects/proj_abc123/tasks/task_cc451 Content-Type: application/json {"name":"projects/proj_abc123/tasks/task_cc451","title":"Test task",...} # PATCH and verify only changed fields are different (get before and after) curl -s https://api.example.com/v1/projects/proj_abc123/tasks/task_cc451 | jq .status "OPEN" curl -s -X PATCH .../task_cc451 -d '{"status":"DONE"}' | jq .status "DONE" curl -s .../task_cc451 | jq .title "Test task" # title must still be present — PATCH must not wipe it # Delete and verify 204 empty body, then 404 on re-fetch curl -i -X DELETE .../task_cc451 HTTP/1.1 204 No Content curl -i .../task_cc451 HTTP/1.1 404 Not Found
SymptomLikely causeFix
Create returns 200 instead of 201Handler uses a generic "success" status codeExplicitly set HTTP 201 and include the Location header
PATCH wipes fields not in the bodyHandler is doing a PUT-style full replace instead of a mergeImplement 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 DeleteReturn 204 with an empty body; strip the response wrapper from Delete handlers
List returns a bare array [...]Missing wrapper objectWrap in {"tasks": [...], "next_page_token": "..."}
Create returns 201 but body is missing create_timeServer returned the input body unchanged instead of the full created resourceFetch (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:

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

Sources & further reading