Resource modelling, versioning, pagination that survives page 900, over- and under-fetching, and the N+1 problem in its API-layer disguise.
Module F-8 — API Design: REST, GraphQL, and gRPC
What this module covers: An API is a contract you can't unship — once a client depends on it, its shape constrains you for years. This module covers resource modelling and the awkward cases every REST design hits, HTTP method semantics that actually matter (safe, idempotent, cacheable), versioning strategies and the true cost of a
v2, pagination that still works at page 900, error shapes that let clients behave correctly, and the N+1 problem in both its server-side and client-side disguises. Then GraphQL and gRPC: what each genuinely solves and what each costs, with a comparison you can defend.
The Contract You Can't Take Back
Internal code can be refactored. A published API cannot — not without either breaking clients or maintaining both shapes indefinitely. Once a mobile app in the App Store, a partner's integration, or another team's service depends on your response shape, that shape is a liability you own.
This asymmetry is why API design deserves disproportionate care relative to how much code it is. A bad internal abstraction costs you a refactor. A bad API costs you a migration involving people who don't work for you.
Analogy: an API is a doorway in a load-bearing wall. Deciding where it goes takes ten minutes and moving it later means rebuilding the wall — while people are still walking through the door.
Why this matters in production: the specific failure isn't ugliness, it's coupling to internals. If your API returns your ORM models directly (Module F-7's third decay pattern), your database schema has become your public contract. A column rename is now a breaking API change, and you'll discover this the first time you want to normalise a table.
REST: Resources, Then Verbs
REST models the domain as resources — nouns — manipulated through a small set of HTTP methods.
The method semantics are not decoration; infrastructure relies on them:
| Method | Safe (no side effects) | Idempotent | Cacheable |
|---|---|---|---|
| GET | Yes | Yes | Yes |
| HEAD | Yes | Yes | Yes |
| PUT | No | Yes | No |
| DELETE | No | Yes | No |
| POST | No | No | Rarely |
| PATCH | No | No (by default) | No |
Why this is load-bearing: proxies, CDNs, browsers, and client libraries all act on these properties. A CDN will cache a GET. A client library or a service mesh may retry an idempotent request automatically and not retry a POST. So a GET with side effects is a genuine bug — something will replay it — and a POST that isn't idempotent needs an explicit idempotency key if it can be retried at all (Module P-16).
The awkward cases, because pure resource modelling doesn't cover everything:
Actions that aren't CRUD. "Cancel an order," "retry a payment," "send a reminder." Three options, and the honest ranking:
- Model the state change as a resource update:
PATCH /orders/8241 { "status": "cancelled" }. Clean, but hides that cancelling runs refunds and inventory releases — it looks like a field assignment and isn't. - Model the action as a sub-resource:
POST /orders/8241/cancellation. Slightly contrived, but honest about being an operation with effects, and naturally supports an idempotency key. - Just use a verb:
POST /orders/8241/cancel. Not RESTful; entirely clear. Widely done by APIs people like using.
Pick the third if it's clearer, and don't apologise. Purity that obscures behaviour is not a win.
Search. GET /orders?status=pending&customer=42 works until the filter set grows into a query language — at which point you've accidentally built one, with no grammar, no cost limits, and a query planner made of if statements. Better to recognise that early and either constrain filters to a fixed supported set or move to a dedicated search endpoint (Module P-20).
Composite reads. A screen needing an order, its customer, and its shipping status shouldn't force three round trips. GET /orders/8241?include=customer,shipment is the pragmatic REST answer, and this exact pressure is what GraphQL was built for.
Status codes worth using precisely: 400 (malformed), 401 (not authenticated), 403 (authenticated, not allowed), 404 (absent — or hidden from you), 409 (conflict, e.g. a version mismatch), 422 (well-formed but semantically invalid), 429 (rate limited — always with Retry-After), 503 (temporarily unavailable — also with Retry-After). The distinction between 401 and 403 matters because clients act differently: one re-authenticates, the other must not retry.
Versioning, and the Real Cost of v2
Every API changes. The question is who absorbs the change.
URL versioning (/v1/orders, /v2/orders) — explicit, visible in logs, trivially routable. The standard choice for public APIs.
Header versioning (Accept: application/vnd.api.v2+json) — keeps URLs stable and is theoretically purer, but is invisible in logs and browser testing, and easy for clients to get wrong.
Never break the contract — the approach that scales best. Add fields, never remove or repurpose them; make new fields optional; keep old behaviour when a new parameter is absent. This is expand/contract applied to APIs (Module O-4 applies it to schemas).
The cost of v2 is the part that gets underestimated: you now operate two APIs. Both need bug fixes, security patches, tests, monitoring, and documentation. Every new feature raises "do we backport this?" Empirically, v1 does not get switched off on schedule — a partner integration built four years ago still uses it, and nobody can reach the person who wrote it.
So the honest guidance: treat a new version as a last resort, and additive change as the default. When you must version, publish the deprecation date at launch, instrument per-version usage so you know who's left (Module O-1), and accept that "sunset in 12 months" is a negotiation rather than a decision.
Pagination: The One That Breaks at Scale
Offset pagination is what everyone writes first:
The database cannot skip rows without producing them. OFFSET 18000 means fetching and discarding 18,000 rows to return 20 — so page 900 is roughly 900× the work of page 1. Deep pagination is a reliable way to turn a fine-looking endpoint into a timeout, and it's how scrapers and buggy clients take down APIs without meaning to.
It has a second, subtler bug: results shift under the reader. If a new row is inserted at the top between requests, page 2 re-shows an item from page 1. Users see duplicates and miss records, and the report they exported is quietly wrong.
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion