Skip to content

API Design

An API is a long-lived contract between independently changing components. Optimize it for clear semantics, safe evolution, predictable failure, and operability—not for mirroring internal tables or methods.

Design from Use Cases

Before choosing a protocol, define:

  • consumers and their critical workflows;
  • resources or operations and their invariants;
  • expected request sizes, rates, and latency;
  • authentication and authorization boundaries;
  • retry, concurrency, and partial-failure behavior;
  • compatibility and retirement policy.

Start with a few representative requests and responses. An API that makes the main workflow awkward has the wrong abstraction even if its schema looks tidy.

Choosing a Style

Style Good fit Important cost
HTTP resource API Public or broadly compatible request–response APIs Client-specific aggregation can take several calls
GraphQL Clients need different projections over a connected schema Query cost, caching, and authorization complexity
gRPC Typed internal calls and streaming in controlled environments Browser and human-debugging friction
Asynchronous messages Decoupled workflows and event distribution Eventual results, duplicates, ordering, schema evolution

Use one style well before combining several.

HTTP Resource APIs

Model stable domain resources with nouns:

GET    /orders/42
POST   /orders
PUT    /orders/42/address
PATCH  /orders/42
DELETE /orders/42

HTTP method semantics matter:

  • GET and HEAD are safe and idempotent;
  • PUT and DELETE are idempotent by definition of their intended effect;
  • POST and PATCH are not inherently idempotent;
  • a response code and headers remain meaningful to generic clients and intermediaries.

Idempotent does not mean “identical response” or “no logging”; it means repeating the same request has the same intended effect. For non-idempotent create or payment operations, accept a client-generated idempotency key and persist its result for a documented scope and duration.

Use status codes consistently:

  • 200 successful response;
  • 201 resource created, usually with Location;
  • 202 accepted for asynchronous processing, with a way to inspect status;
  • 204 success without a response body;
  • 400 malformed request;
  • 401 missing or invalid authentication;
  • 403 authenticated but not permitted;
  • 404 resource not found or intentionally concealed;
  • 409 state conflict;
  • 412 failed precondition;
  • 422 syntactically valid content that cannot be processed as requested;
  • 429 rate limit exceeded;
  • 5xx server or upstream failure.

The current semantics are defined by RFC 9110.

Requests and Representations

  • Use consistent field names, types, units, time zones, and nullability.
  • Distinguish absent, null, empty, and default values deliberately.
  • Represent timestamps in an unambiguous standard form and document precision.
  • Treat identifiers as opaque strings at boundaries.
  • Set media types correctly and reject unsupported types clearly.
  • Put secrets in authorization mechanisms, not URLs or logs.
  • Set explicit request and response size limits.

Do not expose internal database columns merely because serialization is easy. Public representations should remain stable as storage evolves.

Validation and Errors

Validate syntax, shape, ranges, relationships, permissions, and current state. Return all safe, actionable field errors when useful, but never expose stack traces, queries, secrets, or internal topology.

RFC 9457 defines application/problem+json:

{
  "type": "https://api.example.com/problems/out-of-stock",
  "title": "Item is out of stock",
  "status": 409,
  "detail": "Quantity 3 is unavailable",
  "instance": "https://api.example.com/problems/occurrences/abc123",
  "item_id": "sku-7"
}

Clients should branch on stable machine-readable fields such as type, not parse human-readable detail text. Include a request or trace identifier through a safe field or header for support correlation.

Pagination, Filtering, and Sorting

Offset pagination is easy and supports page numbers, but large offsets can be expensive and concurrent inserts can shift results. Cursor pagination is more stable and efficient for changing large collections.

{
  "items": [{"id": "o_123"}],
  "next_cursor": "opaque-token"
}

A cursor should be opaque to clients, encode or reference the complete ordering position, be integrity-protected when clients could tamper with it, and have documented expiry behavior.

Define an immutable tie-breaker for sorting, such as (created_at, id). Allowlist filter and sort fields; do not translate arbitrary client strings into SQL.

Concurrency

Prevent lost updates with conditional requests:

GET /documents/7
ETag: "revision-12"

PUT /documents/7
If-Match: "revision-12"

If the representation changed, return 412 Precondition Failed. Domain commands may instead use an explicit expected version. Document whether bulk operations are atomic, partially successful, or asynchronous.

Long-Running Operations

For work that cannot finish within the request deadline:

  1. accept the request and return 202;
  2. return a stable operation URL or identifier;
  3. expose state such as pending, running, succeeded, failed, or cancelled;
  4. make submission idempotent where duplicates matter;
  5. define result retention and cancellation semantics.

Authentication and Authorization

Use established authentication protocols and validate authorization for the specific resource and action on every request. Tenant identity must come from trusted credentials or server-side mapping, not an unverified request field. Apply field-level controls when a representation mixes data with different sensitivity.

See Authentication and Security.

Rate Limiting and Overload

Define limits by relevant identity—tenant, user, credential, route, or cost—not only IP address. Return 429 for policy limits and 503 for temporary service inability when appropriate. Communicate retry timing when known. Expensive GraphQL or search requests may need cost-based limits.

For broader design considerations, see Rate Limiter.

Compatibility and Versioning

Prefer additive evolution:

  • add optional fields and endpoints;
  • tolerate unknown response fields;
  • do not change the meaning or type of existing fields;
  • provide defaults for newly optional request behavior;
  • measure use before removing anything.

Breaking changes require an explicit migration path, overlap period, owner, telemetry, and retirement date. A version in the path, header, or media type does not replace this process. Avoid versioning until an incompatible contract genuinely needs it.

GraphQL Notes

  • Design the schema around domain concepts, not storage tables.
  • Enforce authentication, field-level authorization, depth or complexity limits, and bounded pagination.
  • Batch or prefetch to avoid N+1 data access.
  • Distinguish transport success from field errors and partial data.
  • Evolve with additive fields and deprecation telemetry.

GraphQL reduces over-fetching for some clients; it does not remove backend cost or authorization work.

gRPC Notes

  • Keep protocol buffer field numbers stable and never reuse removed numbers.
  • Use deadlines and propagate cancellation.
  • Design idempotency before enabling retries.
  • Bound message sizes and streaming buffers.
  • Map domain failures to stable status and structured details.
  • Test backward and forward compatibility across deployed versions.

Documentation and Operations

Publish a machine-readable contract such as OpenAPI or Protocol Buffers plus examples of normal and failure flows. Document authentication, authorization, limits, idempotency, pagination, compatibility, and support contacts.

Observe request rate, latency, errors, payload sizes, rejected requests, consumer/version usage, and dependency failures. Do not log credentials or sensitive bodies by default.

Review Checklist

  • Does the API fit its main workflows?
  • Are method, status, retry, and idempotency semantics correct?
  • Are authorization and tenant boundaries enforced server-side?
  • Are payloads, query cost, pagination, and concurrency bounded?
  • Are errors stable, actionable, and safe?
  • Can the contract evolve without coordinated deployment?
  • Is deprecation backed by telemetry and a migration plan?
  • Can operators trace failures without exposing sensitive data?