REST and API Design
On this page
API contracts define not just paths and JSON fields, but also when an operation takes effect, whether it can be retried after failure, how concurrent modifications are handled, and how the client determines the next step. REST provides architectural constraints; HTTP provides semantics such as methods and status codes; and tools like OpenAPI describe the interface. The three cannot replace each other.
This article uses an order resource to walk through creation, updates, asynchronous processing, and pagination, prioritizing answers to what callers should do in the face of real-world failures and concurrency.
| Question | Key Mechanism |
|---|---|
| Request timed out; was the order actually created? | Operation identity, idempotency keys, and result queries |
| Two people modify the order simultaneously | Strong ETags, If-Match, and atomic compare-and-update |
| Duplicates or omissions appear when paginating | Stable sorting, composite cursors, and snapshot boundaries |
| When is a 202 considered successful? | Queryable operation resources and terminal states |
| Why does adding a field break old clients? | Consumer compatibility assumptions and contract checks |
| How to choose between REST, GraphQL, and gRPC? | Interactions, evolution, and tooling requirements |
REST Constraints Are Not a URL Naming Scorecard
REST includes client/server separation, stateless interactions, cacheability, a uniform interface, layered system, and optional on-demand code. The uniform interface also includes resource identification, manipulation of representations through representations, self-descriptive messages, and hypermedia as the engine of application state; it is not just about removing verbs from URLs.
Stateless means requests cannot rely on server-side implicitly stored interaction session context to be understood; it does not prohibit databases, business resources, caches, or authorization data. It also does not require the use of JWT: the format of authentication credentials and whether the interaction constraint is satisfied are different issues.
Resources can be orders, payment attempts, or export tasks, and do not necessarily map one-to-one to database tables. The JSON for a resource is one representation, not the resource itself. An HTTP API that only provides JSON CRUD can be very practical, but you cannot claim it satisfies all REST constraints based on that alone. Fielding: REST Architectural Style
Use Complete Business Actions to Determine Resource Boundaries
| Action | Example Interface | Contract Key Points |
|---|---|---|
| Create Order | POST /orders | Returns 201 with the new resource Location, or explicitly accepts asynchronously |
| Read Order | GET /orders/42 | Returns representation, ETag, and caching strategy |
| Replace Writable Representation | PUT /orders/42 | Defines how to handle server-generated fields and omitted fields |
| Partial Modification | PATCH /orders/42 | Specifies patch media type and atomicity |
| Initiate Refund | POST /orders/42/refunds | Models refund attempts, amounts, and states, rather than side effects via GET |
| Query Export Task | GET /operations/export-7 | Returns running status, terminal result, or failure information |
Using nouns in paths helps with consistency, but POST does not only represent creation, and action-oriented interfaces are not inherently wrong. What is truly dangerous is using GET to trigger deletions or debits, because caching, prefetching, and retries rely on their safe semantics.
Request contracts must also clarify identity and tenant scope, the difference between missing fields and null, currency units, time formats, and boundary values. For example, whether amount=100 means 100 yuan or 100 cents affects correctness more than whether the path contains a verb.
Method Idempotency, Business Idempotency, and Retryability
The expected semantics of safe methods are reads; they do not require the complete absence of side effects like logging. Idempotent methods have the same expected effect when the same request is sent multiple times as when sent once; they do not require the response status, timestamp, or audit records to be identical each time. A DELETE returning 204 the first time and 404 subsequently does not automatically violate idempotency.
GET, HEAD, PUT, DELETE, etc., have corresponding idempotent semantics; POST has no universal idempotency guarantee, but specific operations can achieve idempotency through contracts. PATCH does not guarantee idempotency as a whole; it depends on the operation content. Whether a request can be retried after a network failure also depends on whether the request body is replayable, the execution state, and operation prerequisites, not just the method name. RFC 9110: Method Semantics
Idempotency Keys Must Cover the Gap Between Submission and Crash
Assume the client submits an order, times out, and retries with the same K. The server should identify K within the agreed caller/tenant and operation scope, bind a normalized request digest, and return the existing operation result for identical inputs; different amounts for the same key should be rejected, not silently reuse the previous order.
Creating the order first, then writing the idempotency record, means if a crash occurs in between, the order might be created twice. Writing only a "processed" record first, then creating the order, might cause unexecuted operations to be swallowed forever. Deduplication claims, business changes, and completion records need to be incorporated into appropriate transactions or recoverable state machines, and concurrent requests with the same key must be handled.
If you also need to call an external payment system, local database transactions do not automatically cover external side effects. Downstream systems need to provide reusable operation keys, result queries, or use workflows with persistent state to resolve uncertainty. How to handle repeated Ks after records expire is also part of the contract; you cannot claim a finite TTL cache provides permanent exactly-once semantics.
Concurrent Updates: ETags Are Not Decorative Versions
Clients A and B both read version v7. After A successfully updates with If-Match: "v7", B should receive a 412 if it still sends v7, and should re-read or let the user handle the conflict. If-Match uses strong comparison; weak ETags cannot be used to fake write concurrency control.
Validation must participate in atomic writes. The following in-memory SQLite example expresses comparison and update using a conditional UPDATE; it demonstrates two clients holding the same old version without relying on real network or services.
=
= .
return 204
assert == 204
assert == 412
assert ==
Real interfaces must also distinguish between resource not found, caller unauthorized, and precondition failure; this example omits these layers to verify only the core mechanism of lost updates. If the response representation is negotiated or transformed, ensure the version pointed to by the ETag has a clear relationship with the protected update target. RFC 9110: If-Match
PATCH Media Type Determines Modification Method
JSON Merge Patch merges object members; null is used to remove members; arrays are typically replaced entirely; therefore, it is not suitable for expressing "set member value to JSON null" without conventions. JSON Patch uses operations like op, path, and value; adding the same value to an array twice via add appends it twice, and not all array operations are non-idempotent. RFC 7396, RFC 6902
PATCH requires applying the entire set of changes atomically according to the specification; it cannot fail halfway and leave a partial patch. For patches constructed from an old representation, combined with If-Match, it avoids applying assumptions about array positions or old values to resources that have changed. RFC 5789
Two clients hold v7: whose update survives?
Submit A then B: B’s old condition fails and A remains. Reverse the order for the symmetric case. B can reread before a new submission; automatic conflict resolution would require business semantics.
A stale submission gets412 without overwriting committed data. Reading the new version before resubmitting is a new explicit decision.
Errors and Asynchronous States Should Drive Programs
application/problem+json uses type to express the problem category, title to provide a short name, detail to describe the specific situation, and instance to identify the problem instance; extension members are defined by the API. Clients should make decisions based on stable categories and fields, not parsing detail text which may change or be localized.
The HTTP status here should also be 409; the status in the body is informational and cannot replace the actual HTTP status. order_id etc. are API extensions, not mandatory fields for Problem Details. Do not return internal stack traces or database statements as detail. RFC 9457
202 only means the request has been accepted; it does not promise successful processing. Export interfaces can return an operation resource link; the client polls to get pending/running/succeeded/failed, and fetches the result after success. The contract needs to specify the recommended polling rhythm, whether cancellation is best-effort, how long tasks and results are saved, and how to identify repeated submissions.
401 usually accompanies authentication challenges; 403 expresses refusal to fulfill, and does not necessarily prove the user has been identified; 404 may also be used to hide whether a resource exists. 409 is used for business state conflicts, 412 for condition failures; do not compress all conflicts into 500.
Cursor Pagination: Stable Sorting Does Not Equal Fixed Snapshots
With sorting by created_at DESC, id DESC, id breaks ties for the same timestamp. The last item of the first page is (100, 8); the next page should take records with keys strictly less than (100, 8); using only a created_at cursor would miss other records with the same timestamp.
=
=
=
assert ==
# Insert an earlier entry after the first page.
=
=
=
assert in # offset drifts, repeats the last item of the first page
assert ==
assert == # Single-field cursor misses (100,7)
Database queries need corresponding indexes; the cursor can encode sorting keys, filter condition versions, and necessary snapshot identifiers, but base64 is not encryption or tamper-proof. Each page still needs to check tenant and permissions; you cannot bypass authorization just because the cursor came from the server.
Keyset pagination reduces drift caused by inserting earlier records, but does not automatically provide a consistent snapshot: deleted records or modified sorting fields can still change results. If you need to export the full set at a fixed moment, you need a snapshot or other explicit consistency scheme. Jump-to-page requirements should also be designed according to the scenario; do not absolutely define cursors as "never allowing jumps."
Hypermedia and OpenAPI: Discovering Next Steps and Describing Fixed Contracts
Responses can provide next links or currently allowed operations and their media type semantics. Hiding cancel links helps client presentation, but the server still needs to check order status and permissions during execution, because the state may change after reading. Simply adding a _links object does not mean the client can understand arbitrary actions without conventions.
OpenAPI describes HTTP interfaces, not limited to strict REST. The following uses 3.1.1 syntax to provide a minimal read-only interface that can be read independently; it does not claim to be the latest version, nor does it cover the entire order contract.
openapi: 3.1.1
info:
title: Order Example
version: 1.0.0
paths:
/orders/{id}:
get:
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Order found
headers:
ETag:
schema:
type: string
content:
application/json:
schema:
type: object
required:
properties:
id:
type: string
state:
type: string
'404':
description: Order not found or not visible
Generating clients, interactive documentation, and structural validation can reduce manual bias, but will not verify that debits happen exactly once, nor derive cross-field business constraints from field types. Contract tests should cover state changes, old client versions, and typical errors, not just check if successful JSON can be parsed. OpenAPI 3.1.1
Style Choices: Compare Specific Interactions, Do Not Give Fixed Performance Rankings
| Solution | Core Interaction | Design Work Required |
|---|---|---|
| Resource-based HTTP API | Exchange representations by resource and common methods | Granularity, conditional requests, caching, and evolution |
| GraphQL | Select fields and execute queries/mutations by schema | Query cost, field authorization, N+1, and partial errors |
| gRPC | Call by service methods and message contracts | Deadlines, status, streaming backpressure, and protocol access |
REST does not limit JSON; the GraphQL language specification does not limit HTTP, and cannot be directly defined as "only single POST so cannot be cached." HTTP deployments can support GET queries and persisted queries, thereby leveraging appropriate caching, while still needing to handle user-specific result isolation. GraphQL over HTTP Guide
gRPC commonly uses Protobuf and HTTP/2, supporting different streaming call forms, but not every interface automatically has low latency. Browsers usually access via gRPC-Web or other adaptation schemes; proxies do not necessarily have to be Envoy, and can be handled directly by servers supporting the protocol; streaming capabilities need to be verified according to the chosen implementation. gRPC Concepts, gRPC-Web Project
Compatibility: Adding Fields Also Requires Consumer Contracts
On clients that agree to ignore unknown fields, adding optional response fields is usually compatible; strict deserialization, closed schemas, exhaustive enums, or signed input rules may fail because of this. Adding required request fields, changing defaults, tightening validation, changing sorting, or changing pagination semantics can all be actual breaking changes.
Versions can be placed in the path or negotiated media types, etc.; the important thing is that routing, cache keys, and generated clients are consistent. Simply changing v1 to v2 cannot replace migration instructions, old version observability, and explicit support periods.
Deprecation expresses the deprecated status, while Sunset expresses the expected service termination time; the two are different. Current Deprecation fields use the Structured Field Date format, e.g., @<unix seconds>; Sunset uses HTTP dates; you cannot copy the same string to both. RFC 9745, RFC 8594
Continue Reading
- HTTP/1.1: Caching, methods, and conditional requests.
- Reverse Proxy: Retry budgets and failure boundaries.
- WebSocket and SSE: Event delivery for task progress, etc.