The API Is a Contract
An API is the boundary where your system meets the world — and like any contract, its failure modes show up later: a client you can’t evolve, a field you can’t change, an endpoint that breaks every mobile release. Most API pain is not “we couldn’t build it” — it’s “we couldn’t change it.” So this topic is about designing for evolution: versioning, pagination, idempotency, error semantics, and knowing when REST isn’t the right shape at all.
The litmus test for every API decision: “In six months, when this changes, how many clients break?” If the answer is “many,” the design is wrong.
Resource Modeling
From the HTTP topic you have the verbs; the noun side is where modeling happens:
- Noun resources, not verbs —
/orders,/orders/42; never/getOrder,/createOrder. - Nested resources for containment —
/orders/42/items(items belong to the order), but avoid going too deep — flatten with query params (/orders?status=open) when nesting becomes a maze. - Sub-resources as concepts —
/users/42/subscriptions,/orders/42/cancelshould really be a state change:POST /orders/42/statuswith a body, or a dedicated action endpoint only when a method can’t express it. - Consistent collection semantics —
GET /orders→[ ... ];GET /orders/42→ single object;POST /orders→ 201 + Location;DELETE /orders/42→ 204.
Versioning: The Evolution Strategy
You will break clients. The question is whether you break them gracefully:
- URL versioning —
/v1/orders,/v2/orders. Obvious, cache-friendly, unforgettably visible in logs. The default for public APIs. - Header versioning —
Accept: application/vnd.example.v2+json. Clean URLs, but invisible in URL-based analytics and easy to forget. - Query-param versioning —
?version=2. Simple, but pollutes cache keys.
Operational guidance: version when the contract changes semantically (a field meaning changes, a field is removed, required fields change). Additive changes — new optional fields, new endpoints — do not require a version bump, and disciplined additive evolution is how APIs avoid a /v9. Deprecate loudly (Deprecation header, sunset dates) and remove only when traffic metrics say it’s safe.
Pagination: Don’t Dump the Whole Table
Unbounded GET /orders is how you accidentally DoS yourself on day one of real traffic. Two shapes:
Page-based (?page=2&size=50) | Cursor-based (?cursor=eyJpZCI6...) | |
|---|---|---|
| Meaning | Offset window | Opaque pointer into the result set |
| Stable under inserts? | No — items shift across pages | Yes — cursors anchor to a position |
| Deterministic ordering | Only if sorted by unique key | Built into the cursor |
| Best for | Admin UIs, small data | Feeds, infinite scroll, large volatile sets |
Rules that prevent most bugs: always return total counts/next links (next, previous, or a Link header) rather than making clients compute pagination math; sort by a unique key so page boundaries are well-defined; and cap the page size server-side (a requested size of 10,000 is a code smell — clamp it).
Idempotency and Retries
Networks drop requests. When a client retries a POST /payments, the server must not charge twice. The standard fix is an idempotency key:
- Client sends
Idempotency-Key: <uuid>on the first attempt. - Server processes, stores the key → response.
- If the client retries with the same key (after a timeout), the server returns the stored response instead of processing again.
This is why the method table in the HTTP topic matters: GET/PUT/DELETE are naturally idempotent and safe to retry; POST is not — so any retryable POST needs a key. Design the retry semantics into the API (document max retries, backoff, and key expiry) or your clients will reinvent them badly.
Structured Error Semantics
Error responses are the most-ignored, most-copied part of an API — and the one your clients actually code against. The shape that survives contact:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/order-total",
"title": "Order total below minimum",
"status": 422,
"detail": "Minimum order total is 10.00",
"instance": "/orders/42",
"field_errors": { "total": ["must be at least 10.00"] }
}
Rules: use the correct status class (client errors are never 5xx); give a machine-readable code plus a human detail; include a stable error type clients can switch on; include field-level errors for validation. And never return {"error": "something went wrong"} — that text belongs in your logs, not the client’s UI.
Beyond REST: gRPC and GraphQL
REST is the default, but not the answer to everything:
- gRPC — HTTP/2 + Protocol Buffers. Strongly-typed schemas, binary (small + fast), first-class streaming and codegen. Ideal for internal service-to-service communication where you control both ends and need RPC semantics (see Distributed Systems). Trade-offs: binary debugging is harder, no browser-native JSON, awkward over restrictive networks.
- GraphQL — one endpoint, clients select fields and relations. Great when clients are heterogeneous (mobile vs web) and you want to avoid N+1 round trips. Trade-offs: no HTTP caching on the envelope, complexity moves server-side, and the “one endpoint” model fights existing middleware.
The decision matrix:
| Need | Choose |
|---|---|
| Public/human/tooling-friendly, cacheable | REST |
| High-throughput internal service calls, streaming | gRPC |
| Heterogeneous clients, field-selective queries | GraphQL |
| Event streams / pub-sub | Async events (not request/response at all) |
The anti-pattern is choosing by hype: a public SDK-less API as gRPC, or an internal CRUD service as GraphQL, usually signals the wrong trade-off.
Practice Trajectory
- Design a
/ordersresource model on paper: list the endpoints, methods, and status codes for create/read/update/delete/state-change. - Implement cursor pagination on a test dataset and prove it stays stable while rows are inserted mid-pagination.
- Add an
Idempotency-Keyheader to a mockPOST /payments; retry the same key and confirm a single stored response. curl -ia real API that returns 422/429 and inspect whether its error body has a machine-readable code you could program against.- Redesign one of your existing endpoints so it’s evolution-safe (additive-only + a versioning story) — and write the deprecation notice.
When It’s the Right Tool
| Situation | Takeaway |
|---|---|
| Public API | REST + versioning + cursor pagination + idempotency |
| Retry-heavy flows (payments, orders) | Idempotency keys, always |
| Client debugging | Structured errors with types, not "error": true |
| Internal service mesh | gRPC |
| Mobile/web field-selection | GraphQL |
| Every decision | “How many clients break when this changes?” |