REST and API Design

38 min read
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.

QuestionKey Mechanism
Request timed out; was the order actually created?Operation identity, idempotency keys, and result queries
Two people modify the order simultaneouslyStrong ETags, If-Match, and atomic compare-and-update
Duplicates or omissions appear when paginatingStable 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

ActionExample InterfaceContract Key Points
Create OrderPOST /ordersReturns 201 with the new resource Location, or explicitly accepts asynchronously
Read OrderGET /orders/42Returns representation, ETag, and caching strategy
Replace Writable RepresentationPUT /orders/42Defines how to handle server-generated fields and omitted fields
Partial ModificationPATCH /orders/42Specifies patch media type and atomicity
Initiate RefundPOST /orders/42/refundsModels refund attempts, amounts, and states, rather than side effects via GET
Query Export TaskGET /operations/export-7Returns 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

Idempotency keys record business operation identity; they cannot just be an in-memory set Receive Operation Key K Scope caller and operation Atomic Claim / Execute Bind digest & business record Complete and Save Result Repeat K → same result Same key, different input: Conflict; Same key, currently executing: Wait per contract or return status After process crash, confirm what was done based on persistent state Side effects across databases and external payments require additional transaction protocols, downstream idempotency, or recoverable workflows.

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

If-Match must participate in atomic updates to prevent lost updates Client A reads v7 Both parties start editing the same resource Client B also reads v7 Both parties start editing the same resource A: If-Match v7 → Update Success Storage condition hit, version becomes v8 B: If-Match v7 → 412 Old version no longer hits, does not overwrite A Do not read the version in the application first and then write unconditionally; version comparison and updates must form a single concurrency control action.

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.

import sqlite3

db = sqlite3.connect(':memory:')
db.execute('CREATE TABLE orders(id INTEGER PRIMARY KEY, note TEXT, version INTEGER NOT NULL)')
db.execute('INSERT INTO orders VALUES (42, ?, 7)', ('initial',))

def update(expected, note):
    changed = db.execute(
        'UPDATE orders SET note=?, version=version+1 WHERE id=42 AND version=?',
        (note, expected)).rowcount
    db.commit()
    return 204 if changed == 1 else 412

assert update(7, 'from A') == 204
assert update(7, 'from B') == 412
assert db.execute('SELECT note, version FROM orders WHERE id=42').fetchone() == ('from A', 8)
print('Old version update rejected, A modification preserved')

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.

Preparing the visual
Two clients hold v7: whose update survives?

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.

{
  "type": "https://api.example.com/problems/order-state-conflict",
  "title": "Order state conflict",
  "status": 409,
  "detail": "A shipped order cannot be cancelled.",
  "instance": "/problem-instances/req-123",
  "order_id": "42",
  "current_state": "shipped"
}

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.

rows = [(101, 10), (100, 9), (100, 8), (100, 7), (99, 6)]
first = sorted(rows, reverse=True)[:3]
cursor = first[-1]
assert cursor == (100, 8)
# Insert an earlier entry after the first page.
now = sorted(rows + [(102, 11)], reverse=True)
offset_page = now[3:6]
keyset_page = [r for r in now if r < cursor][:3]
assert (100, 8) in offset_page  # offset drifts, repeats the last item of the first page
assert keyset_page == [(100, 7), (99, 6)]
assert [r for r in now if r[0] < cursor[0]] == [(99, 6)]  # Single-field cursor misses (100,7)
print('offset:', offset_page, 'composite cursor:', keyset_page)

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: [id, state]
                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

SolutionCore InteractionDesign Work Required
Resource-based HTTP APIExchange representations by resource and common methodsGranularity, conditional requests, caching, and evolution
GraphQLSelect fields and execute queries/mutations by schemaQuery cost, field authorization, N+1, and partial errors
gRPCCall by service methods and message contractsDeadlines, 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