Resource Design Patterns · Lesson 03
Custom methods — when CRUD isn't enough
Most resources live happily inside create, read, update, and delete. But some operations — completing a task, moving it between projects, archiving a whole workspace — carry meaning and side effects that a plain data write can't express. Custom methods give you a principled, spec-backed way to reach beyond CRUD without losing REST's identity.
By the end you'll be able to
- Explain the Google AIP-136 colon-suffix convention and why custom methods always use POST.
- Apply a decision rule to choose between a custom method, a PATCH on a status field, and a sub-resource.
- Design complete request/response contracts for
:completeand:moveagainst the Tasks API, including idempotency and state-machine behaviour.
Why CRUD breaks down
Imagine you run a postal sorting facility. The standard operations are obvious: add a parcel (create), look it up (read), update the delivery address (update), remove a cancelled shipment (delete). But what happens when a parcel ships? Shipping isn't just writing status = "shipped" — it triggers downstream effects: the carrier is notified, the tracking number is generated, the warehouse inventory is decremented, and the customer gets an email. The operation has a name — "ship this parcel" — and a contract — it either succeeds fully or rolls back. Squashing that into a PATCH call obscures intent, scatters side-effect logic, and gives your callers no signal that something significant just happened.
This is the gap that custom methods fill. They are purpose-named operations on a specific resource, distinct from its CRUD lifecycle, with their own request payload and response contract.
The colon-suffix convention
Google's API Improvement Proposals (AIPs) codify this pattern as AIP-136. The rule is simple: append a colon and a verb to the resource URL, and always use POST.
# Custom method on a single resource
POST /v1/projects/{project}/tasks/{task}:complete
POST /v1/projects/{project}/tasks/{task}:move
POST /v1/projects/{project}/tasks/{task}:assign
# Custom method on a collection or parent resource
POST /v1/projects/{project}:archive
POST /v1/projects/{project}:restore
POST /v1/documents/{document}:publish
Why always POST?
You might expect a "get suggestions" custom method to use GET, and an argument could be made. But AIP-136 mandates POST for all custom methods for three reasons:
- Side effects. Custom methods almost always mutate state or trigger external actions. POST is the correct semantic for non-safe, non-idempotent-by-default operations. Using GET for a side-effectful call violates the HTTP safety contract and risks being silently re-fetched by proxies and prefetch heuristics.
- Request body. Many custom methods need parameters — a destination project ID for
:move, or a reason for:archive. GET has no defined body semantics. POST gives you a structured JSON body without resorting to query-string encoding of complex data. - Uniformity. A single rule ("custom methods are always POST") is simpler to implement, document, and lint than per-method exceptions. Router logic and auth middleware only need one pattern to match.
Why a colon, not a slash?
A slash would make :complete look like a child resource: /tasks/task_77/complete — something you could GET, PUT, DELETE. The colon makes the operation/resource distinction syntactically unambiguous. The resource is /tasks/task_77. The colon-verb is an action on it, not a sub-resource of it. URL parsing is unambiguous: split on the last colon in the final path segment and you have the resource name on the left and the method name on the right.
Decision tree: custom method vs PATCH vs sub-resource
Before reaching for a custom method, run through this decision order:
| Option | Use when | Example |
|---|---|---|
| PATCH a status field | The operation is a pure data update — it only changes a field, has no observable side effects beyond the field change itself, and the new value could logically be anything the caller writes. | PATCH /tasks/task_77 with {"due_time": "2025-07-01T17:00:00Z"} |
| Create a sub-resource | The operation produces a persistent, independently addressable record that callers will later need to read, list, or delete — the operation is really "create a new thing that lives inside this thing." | POST /tasks/task_77/comments to add a comment; POST /tasks/task_77/attachments to attach a file |
| Custom method | The operation (a) changes state in a way that carries domain meaning beyond a raw field write, (b) triggers observable side effects, (c) enforces a state-machine transition, or (d) moves or copies a resource across ownership boundaries. | POST /tasks/task_77:complete — sets status, stamps completed_time, may notify assignee; the semantics are "complete this task", not "write status=completed" |
The sharpest test: could the caller achieve this by PATCHing a field, and would that be safe? If writing {"status": "completed"} via PATCH would skip the notification, leave completed_time unpopulated, or allow invalid transitions (e.g. completing an already-deleted task), then you need a custom method. PATCH is a data channel; custom methods are a behaviour channel.
SVG decision flowchart
Worked example: :complete
Completing a task in the Tasks API is the classic case for a custom method. It is not a pure field write — it must stamp completed_time atomically, enforce the state machine (you cannot complete an already-deleted task), and may trigger downstream notifications. Here is the full wire exchange:
# Request — empty body is valid; :complete needs no parameters
POST /v1/projects/proj_42/tasks/task_77:complete HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json
{}
# Response — full resource returned, status and completed_time updated
HTTP/1.1 200 OK
Content-Type: application/json
{
"name": "projects/proj_42/tasks/task_77",
"title": "Finalise API spec",
"status": "completed",
"completed": true,
"completed_time": "2025-06-18T14:32:00Z",
"assignee": "users/ada",
"due_time": "2025-06-20T17:00:00Z",
"create_time": "2025-06-01T09:00:00Z",
"update_time": "2025-06-18T14:32:00Z"
}
Several design choices are worth calling out:
- Empty body is allowed.
:completeneeds no caller-supplied arguments. Sending{}is idiomatic; some implementations also accept a missing body. Document which you support. - 200, not 201. You are not creating a new resource — you are mutating an existing one. 200 with the updated resource is the correct status.
- Full resource in the response. AIP-136 recommends returning the full resource so clients can update their local state without a follow-up GET. Never return just
{"ok": true}. - Idempotency. Calling
:completeon an already-completed task should be safe — return 200 with the existing resource unchanged, rather than 409. The task is in the desired final state; returning an error would force callers to check before every call.
Worked example: :move
Moving a task between projects is a cross-resource operation. It cannot be expressed as a PATCH (the resource's canonical URL changes), and it does not create a child record. It is exactly the kind of bounded domain action that custom methods exist for.
# Request — destination_project tells the server where to move the task
POST /v1/projects/proj_42/tasks/task_77:move HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json
{
"destination_project": "projects/proj_99"
}
# Response — task now lives at its new canonical name in proj_99
HTTP/1.1 200 OK
Content-Type: application/json
{
"name": "projects/proj_99/tasks/task_77",
"title": "Finalise API spec",
"status": "open",
"completed": false,
"assignee": "users/ada",
"due_time": "2025-06-20T17:00:00Z",
"create_time": "2025-06-01T09:00:00Z",
"update_time": "2025-06-18T14:33:00Z"
}
The name field in the response reflects the new canonical path — projects/proj_99/tasks/task_77 — not the URL the request was sent to. Any client that cached the old URL needs to update its reference. Note that status is reset to "open": the task enters the destination project's default state. This business rule belongs here, not in a PATCH handler, because it is part of the semantics of "moving" a task.
Under the hood: how it actually works
Understanding what happens in the router and handler helps you design the right contract and debug issues when they arise.
Router parsing
The colon-suffix is parsed as part of the final path segment. A typical routing table entry looks like:
// Express-style pseudo-code
// The router registers the full literal :complete suffix as a method name
app.post('/v1/projects/:project/tasks/:task\\:complete', handlers.completeTask);
app.post('/v1/projects/:project/tasks/:task\\:move', handlers.moveTask);
// Framework-agnostic: split on the last colon in the final segment
function parseCustomMethod(path) {
const lastSegment = path.split('/').pop(); // "task_77:complete"
const colonIdx = lastSegment.lastIndexOf(':');
if (colonIdx === -1) return { resource: lastSegment, method: null };
return {
resource: lastSegment.slice(0, colonIdx), // "task_77"
method: lastSegment.slice(colonIdx + 1) // "complete"
};
}
State machine and idempotency
Custom methods that implement state transitions must be written with idempotency in mind. The contract for :complete is: after this call, the task is in the completed state. If it was already there, that is not an error — it is the desired outcome, already achieved. The handler should check the current state and short-circuit:
// Pseudocode handler for :complete
async function completeTask(req, res) {
const task = await db.tasks.findById(req.params.task);
if (!task) throw notFound('Task not found');
if (task.deleted) throw failedPrecondition('Cannot complete a deleted task');
// Idempotent: already completed → return current state, no-op
if (task.status === 'completed') {
return res.json(toResource(task)); // 200, no mutation
}
const now = new Date().toISOString();
const updated = await db.tasks.update(task.id, {
status: 'completed',
completed: true,
completed_time: now,
update_time: now,
});
await notifications.send(updated); // side effect: notify assignee
res.json(toResource(updated));
}
Always return the full resource
The contract for custom methods that mutate a resource is to return the complete, updated resource representation — not a status flag, not a partial object. This lets clients treat the response as a fresh GET of the resource and update their cache or local state accordingly. It also makes the API easier to reason about in logs and debugging sessions.
Curl commands and routing walkthrough
How to debug & inspect it
Custom method issues usually fall into a handful of recognisable patterns. Here is how to quickly isolate each one.
Symptom → cause → fix
| Symptom | Likely cause | Fix |
|---|---|---|
| 405 Method Not Allowed on a custom method URL | Client sent GET, PUT, or PATCH instead of POST | Custom methods are always POST. Check client code for incorrect method. |
| 404 Not Found for a URL you know exists | The colon is percent-encoded as %3A by the client or an intermediate proxy |
Pass the literal colon in the URL. If behind an API gateway, verify colon pass-through is enabled. URL-decode the path before router matching. |
400 Bad Request with "missing body" on :move |
No request body or missing Content-Type: application/json header |
Always send a JSON body and set the Content-Type header, even if the body is {}. |
Second :complete call returns 409 Conflict |
Handler was not written idempotently — it errors on an already-completed task | Check the current state first. If already in the target state, return 200 with the unchanged resource — do not error. |
Response body is {"ok": true} instead of the full resource |
Handler returns a minimal ack instead of the updated resource | Custom methods must return the full, updated resource. Clients use the response to refresh their local state. |
"Design a task management API. How would you handle 'completing' a task?" The weak answer is PATCH /tasks/{id} with {"status":"completed"} — it works, but signals you haven't thought about side effects. The strong answer introduces a custom method: POST /v1/tasks/{task}:complete. Walk through why: (1) completing triggers server-side actions — notification, timestamp stamping, state-machine enforcement — that a plain PATCH would have to silently bake into its handler; (2) the colon-suffix convention (AIP-136) makes the intent unambiguous in logs, docs, and SDKs; (3) :complete should be idempotent — already-completed tasks return 200 without mutation. Interviewers listen for the PATCH-vs-custom-method distinction and whether you can explain the side-effect argument. Mentioning AIP-136 by name signals you've worked with real API standards.
Custom methods are an escape valve — not a default. The moment they become your primary pattern, you have rebuilt RPC inside HTTP. Signs you've crossed the line: every endpoint is a custom method; verbs like :get, :list, or :update appear (those are standard methods in disguise); the API reads like a command interface rather than a resource graph. REST's value — uniform interface, hypermedia links, cacheability, discoverability — evaporates when 80% of your endpoints are colon-verbs. Reserve custom methods for operations that genuinely cannot be expressed as CRUD on a resource or sub-resource. A healthy API has dozens of standard methods and a handful of custom ones.
Before adding a custom method, ask: can this operation be fully described as a state machine transition? If yes, it probably deserves a custom method. "Completing" a task is a transition from open or in_progress to completed, and the transition carries domain rules. "Changing the due date" is not a state machine transition — it is a field update, so PATCH is correct. The state-machine lens also reveals idempotency requirements: if the system is already in the target state, the transition is a no-op, not an error.
🧠 Quick check
1. What HTTP method does a custom method always use?
AIP-136 mandates POST for all custom methods. Custom methods almost always have side effects and often need a request body — both of which make POST the correct semantic choice. Using GET would violate HTTP's safety contract and risk unintended re-execution by proxies and prefetch mechanisms.
2. Which URL pattern correctly represents a custom method to archive a project?
The colon-suffix on the resource path segment is the AIP-136 convention: /v1/projects/proj_42:archive. A slash before "archive" would make it look like a child resource. A query parameter encodes the action in a non-standard way. DELETE removes the resource rather than archiving it — a semantically different operation.
3. When should you prefer a PATCH on a status field over a custom :complete method?
PATCH is the right choice for pure data updates — changing a due date, updating notes, renaming a task. When the operation triggers side effects, enforces a state machine rule, or stamps server-computed fields, a custom method is the better fit because it makes those semantics explicit in the API surface rather than hiding them inside a generic PATCH handler.
4. What is the primary risk of overusing custom methods?
When custom methods become the dominant pattern, the API devolves into a command-oriented RPC interface. REST's benefits — uniform resource addressing, cache-friendliness, self-describing hypermedia, and discoverability — all rely on the noun/verb discipline of the resource model. An API full of colon-verbs has abandoned that discipline.
✍️ Exercise: design a :publish custom method for a Documents API (try before opening)
You are designing a Documents API with a resource at /v1/workspaces/{workspace}/documents/{document}. A document has fields: title, body, status (draft / published / archived), published_time, create_time, update_time. Publishing a document: sets status to published, stamps published_time, and sends a notification to all workspace members. Design the custom method — URL, HTTP method, request body, response, idempotency behaviour, and error for invalid transitions.
Model answer:
- URL and method:
POST /v1/workspaces/{workspace}/documents/{document}:publish. The colon-suffix convention per AIP-136; POST because publishing triggers side effects. - Request body:
{}for the minimal case. Optionally accept{"notify_external": true}if external notifications are configurable. Keep the body schema narrow — input specific to the publish action only. - Success response:
200 OKwith the full updated document resource:status: "published",published_timestamped server-side,update_timerefreshed. - Idempotency: If the document is already
published, return200with the existing resource and do not re-send notifications. The method's postcondition is "document is published" — if already true, the call is a no-op, not an error. - Invalid transition: If status is
archived, return400 Bad Requestwith"status": "FAILED_PRECONDITION"and message"An archived document cannot be published directly. Restore it first."A deleted document (soft-deleted / 404) should return404 Not Found.
# Successful publish
POST /v1/workspaces/ws_1/documents/doc_88:publish
Content-Type: application/json
{}
# Response
HTTP/1.1 200 OK
{
"name": "workspaces/ws_1/documents/doc_88",
"status": "published",
"published_time": "2025-06-18T15:00:00Z",
"update_time": "2025-06-18T15:00:00Z"
}
Rubric: ✓ Colon-suffix URL ✓ POST method ✓ 200 with full resource ✓ server-stamped published_time ✓ idempotent (no re-notify on already-published) ✓ FAILED_PRECONDITION for archived state. Five out of five = senior-level answer; missing idempotency handling or returning {"ok":true} instead of the full resource drops to mid-level.
Key takeaways
- Custom methods follow the colon-suffix convention (AIP-136): append
:verbto the resource URL and always usePOST. - Use a PATCH for pure data updates with no side effects; use a sub-resource when the operation creates an independently addressable child record; use a custom method when the operation has domain semantics, side effects, or enforces a state transition.
- Custom methods should be idempotent where the operation is a state transition: calling
:completeon an already-completed task returns 200, not 409. - Always return the full updated resource from a custom method — not a boolean ack. Clients use the response to refresh their local state.
- The colon distinguishes actions from sub-resources syntactically:
/tasks/task_77:completeis an action;/tasks/task_77/commentsis a child resource. - Keep custom methods rare. A healthy REST API has many standard methods and only a small set of custom ones for genuinely irreducible domain operations. When custom methods become the norm, you have rebuilt RPC.
Sources & further reading
- Google AIP-136 — Custom Methods — the normative specification for the colon-suffix convention, including naming guidance and standard custom method names
- Google API Design Guide — Custom Methods — practical guidance from Google on when and how to use custom methods in production APIs
- MDN — HTTP POST method — the canonical reference for POST semantics, including why it is the correct choice for side-effectful operations
- RFC 9110 § 9.3.3 — POST — the normative HTTP specification definition of POST and its relationship to resource mutations
- Google AIP-193 — Errors — error model used in this lesson for FAILED_PRECONDITION and other structured error codes