all_lessons / system_design / 03 · API design lesson 3 / 19

API design

Step 3 of the method from lesson 01: requirements → estimate → API → data model → … Before you argue about boxes, write down what the system actually does as a list of concrete operations. That list is the contract — the promise clients code against. It outlives every implementation behind it, and it is the most expensive thing in the system to change once anyone depends on it.

First principle

You can rewrite the database, swap the language, re-shard, re-cache — all invisibly — as long as the contract holds. The instant a client somewhere parses your JSON, your API stops being your code and becomes a public boundary. So design it deliberately and early: the API forces vague requirements ("users can share photos") into precise verbs and nouns (POST /albums/{id}/photos201 with a photo id). Every choice below — verb, status code, idempotency, pagination, version — buys something for clients and costs you flexibility you can never fully reclaim.

Why design the API before the boxes

A newcomer's instinct is to start drawing servers and databases. But you cannot size a database for an operation you haven't named, and you cannot reason about latency (02) without knowing how many round trips a user action takes. Writing the API first does three things:

In an interview, sketching 4–6 endpoints right after the estimate signals seniority: it shows you reason from what the system promises down to how it keeps the promise, not the reverse.

REST & resource modelling: the contract is the vocabulary

REST (representational state transfer) is less a protocol than a discipline: model your domain as resources — nouns with stable URLs — and act on them with a small fixed set of HTTP verbs. The power is that the verbs and the response codes are a vocabulary every client, proxy and cache already understands. You are not inventing a language; you are reusing HTTP's.

VerbMeansSafe?Idempotent?Typical success code
GET /photos/42read a resourceyes (no side effects)yes200 OK
POST /photoscreate a new resourcenono201 Created (+ Location)
PUT /photos/42replace it wholesalenoyes200 / 204
PATCH /photos/42partially update itnousually no200 / 204
DELETE /photos/42remove itnoyes204 No Content

Safe means "no observable side effect" (a GET must never mutate state — caches and crawlers rely on this). Idempotent means "doing it twice lands you in the same final state as doing it once" — the property the whole reliability story below hangs on. Resources are nouns; resist verbs in URLs (POST /createPhoto is a smell — the verb belongs in the method, not the path).

The status code IS the contract

Clients branch on status codes, so a sloppy or inconsistent code is a broken contract. Learn the vocabulary as three families — 2xx "it worked", 4xx "you messed up, don't retry unchanged", 5xx "I messed up, retry may help":

CodeNameWhen you return itWhat the client should do
200OKread/update succeeded, body returneduse the body
201CreatedPOST created a resourceread Location for its URL
202Acceptedwork queued, not yet done (async)poll the job (see below)
204No Contentsucceeded, nothing to return (DELETE/PUT)proceed, ignore body
400Bad Requestmalformed syntax / unparseablefix the request; do not retry as-is
401Unauthorizedmissing/invalid credentialsauthenticate, then retry
403Forbiddenauthenticated but not allowedstop — retrying won't help
404Not Foundresource doesn't exist (or hidden)stop
409Conflictstate clash (dup create, version mismatch)re-read, reconcile, retry
422Unprocessablesyntax fine, semantics invalid (email taken)fix the data
429Too Many Requestsrate limited (see 15)back off per Retry-After
500Internal Errorunhandled server faultretry with backoff (idempotent only)
503Unavailableoverloaded / down / shedding loadback off, retry later

The 4xx/5xx split is load-bearing for reliability: a client (and its retry library) should retry 5xx and 429 but never blindly retry a 400 or 422 — the request is wrong and will stay wrong. The 400-vs-422 and 401-vs-403 distinctions are exactly the kind of precision an interviewer probes for.

Statelessness — the property that makes scaling free

REST asks each request to be self-contained: it carries everything the server needs (auth token, parameters), and the server keeps no per-client session in memory between calls. Why this matters lands in 05: if no request depends on which server handled the last one, a load balancer can route each request to any replica. Statelessness = self-contained requests = trivially horizontally scalable. The cost: anything that was session state (a shopping cart, a login) must move to a shared store (a token, a cache, a DB) — you trade in-memory convenience for the ability to add boxes without thinking.

REST vs gRPC vs GraphQL — pick by who's calling

These are three answers to "how do client and server talk", and the right one depends almost entirely on who the client is: a public third-party developer, a browser/mobile app, or another one of your own services. Each buys something real and bills you for it elsewhere.

StyleWire / shapeBuysCostsReach for it when
REST + JSONHTTP/1.1+, text JSON, resource URLsubiquitous, human-readable, cacheable by any proxy/CDN (06), trivial to debug with curlchatty (one resource per call); fixed payload causes over-fetch (fields you don't need) and under-fetch (N follow-up calls for related data)public APIs, anything a browser or third party touches, CRUD
gRPCHTTP/2, binary protobuf, IDL-defined RPCcompact + fast, generated typed stubs, bidirectional streaming, multiplexed connections — built for internal RPCnot natively browser-callable (needs a proxy); binary is opaque to humans/curl; harder to cache; schema/codegen toolchain to runservice-to-service inside your datacentre, high call volume, streaming
GraphQLHTTP, single endpoint, client-specified queryclient picks exactly the fields it wants → one round trip for deeply nested data; kills over- and under-fetch; one schema for many client shapesserver complexity (resolvers); HTTP caching is hard (everything is one POST to /graphql); the N+1 resolver problem — a naive nested query fires one DB hit per child unless you batch (DataLoader)mobile/varied front-ends on slow links fetching nested graphs; many client teams, one backend

The honest one-liner: REST for public and simple, gRPC for internal and fast, GraphQL for rich front-ends fetching graphs. Plenty of real systems run all three — gRPC between services, a REST or GraphQL gateway at the edge. Notice the recurring tension: GraphQL trades the client's round trips for the server's resolver complexity and lost edge caching. That round-trip cost is exactly what the widget below makes concrete.

Idempotency as a contract: assume duplicates, always

Here is a scenario you will hit. A client POSTs "charge $50", the charge succeeds, but the response is lost to a flaky network. The client times out and — reasonably — retries. Without protection, you've now charged $100. This is not an edge case: clients retry, load balancers (05) re-dispatch, and fault-tolerance layers (13) replay. Duplicates are the normal case at scale, not the exception.

Some verbs are safe by construction (re-read the table): GET, PUT and DELETE are idempotent — replaying them lands in the same state. POST is not, because each call means "make a new one." So for unsafe creates you make idempotency an explicit term of the contract via the Idempotency-Key header (the pattern Stripe popularised):

client server (idempotency store, TTL = 24h) │ POST /charges │ │ Idempotency-Key: a1b2c3 ───► │ key seen before? │ │ no → execute charge, store (key → response), return 201 │ ◄──────────── 201 Created ────│ │ (response lost in network) │ │ │ │ POST /charges (RETRY) │ │ Idempotency-Key: a1b2c3 ───► │ key seen before? │ │ yes → DON'T execute; replay stored 201 │ ◄──────────── 201 Created ────│ (one charge, not two)

The client generates a unique key per logical operation (a UUID) and resends the same key on retry. The server records "key → outcome" for a dedup window (e.g. 24h) and, on a repeat, returns the stored result without re-executing. Buys: safe retries, so the network can fail freely. Costs: a storage layer keyed by idempotency key, a TTL to bound its growth, and a subtle decision about what counts as "the same request" (same key + different body should arguably be a 422). The deeper version of this — making the side effect itself exactly-once across services — is the subject of 12.

Pagination: never return an unbounded list

A collection endpoint that returns "all the things" is a latency and memory time-bomb the day the collection grows. Every list endpoint must be paginated and must cap the page size (a client asking for limit=10000000 gets clamped, not obeyed). The interesting choice is how you page:

SchemeMechanicBuysCosts
Offset / limit
?offset=200&limit=20
skip N rows, take the next pagedead simple; jump to any page ("page 7")the DB still scans and discards all skipped rows → O(n) deep in the list; and if items are inserted/deleted between pages the window drifts — you re-see or skip rows
Cursor / keyset
?cursor=eyJpZCI6MTIzfQ&limit=20
"give me items after this sort key" — WHERE (created_at,id) < (…) ORDER BY … LIMIT 20stable under concurrent writes (you anchor to a value, not a position); uses the index → O(log n) per page regardless of depthopaque cursor; no random page jumps; needs a stable, unique sort key (usually (timestamp, id))

The default for any feed, log, or large list is cursor pagination: it is the only scheme that stays correct when the underlying list mutates (which feeds always do) and the only one whose cost doesn't grow as users page deeper. Offset is fine for small, mostly-static admin tables where "go to page 7" matters. This ties directly to 07: in a sharded system, offset pagination is nearly unworkable (offset across shards is meaningless), whereas a sort-key cursor merges cleanly across partitions.

Versioning & evolution: change without breaking callers

The contract will need to change, but clients upgrade on their schedule, not yours. The whole game of versioning is shipping change while old clients keep working. First, classify the change:

How you expose the version is a trade:

StrategyExampleBuysCosts
URI path/v1/photos/v2/photosobvious, visible in logs, easy to route/cache"version" leaks into every URL; technically a new resource for the same thing
HeaderAPI-Version: 2clean URLs; one resource, many versionsinvisible in the URL — easy to forget; harder to test in a browser
Media typeAccept: application/vnd.acme.v2+jsonmost "RESTful"; per-representation versioningverbose, unfamiliar to many clients, fiddly tooling

URI versioning wins in practice for public APIs on sheer legibility. Whatever you pick, lean on the tolerant reader principle: clients should ignore fields they don't recognise rather than reject the whole payload — that single discipline turns most additive changes into non-events. And version with a deprecation plan: announce, set a sunset date, emit a Deprecation/Sunset header, then retire. A version you can never delete is a maintenance tax forever.

Error contracts: machine-readable, and never leaking internals

Errors are part of the contract too, and "return a 500 with a stack trace" fails twice: it gives clients nothing to branch on, and it leaks your internals (table names, file paths) to the world. Return a consistent, machine-readable error envelope — the application/problem+json shape (RFC 7807) is a fine default:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type":   "https://api.acme.com/errors/duplicate-email",
  "title":  "Email already registered",
  "status": 409,
  "detail": "An account with this email already exists.",
  "code":   "EMAIL_TAKEN",            // STABLE, client branches on this
  "request_id": "req_8f3a..."          // for support / tracing
}

The keys that matter: a stable machine code (EMAIL_TAKEN) clients can switch on without string-matching the human title (which you must be free to reword); the numeric status mirroring the HTTP code; a human detail; and a request_id so a user can quote it to support and you can find the trace. Buys: clients handle errors deterministically; you can change wording freely. Costs: the discipline of maintaining a stable error-code registry — but never, ever surface raw exceptions, SQL, or internal hostnames; those are a security and coupling leak.

Async & long-running APIs: don't hold the connection open

Some operations — video transcoding, a bulk import, a report — take seconds to minutes. Blocking the HTTP request until they finish ties up a connection, courts client timeouts, and falls apart on retry. The pattern is to make the work asynchronous at the contract level: accept it, hand back a handle, let the client check on it.

client server │ POST /videos/123/transcode ─────► │ enqueue job, return immediately │ ◄── 202 Accepted ──────────────────│ { "job_id": "j_55", "status": "queued" } │ │ (work runs on a queue → lesson 11) │ GET /jobs/j_55 (poll) ─────► │ │ ◄── 200 { "status": "running" } ───│ │ GET /jobs/j_55 (poll) ─────► │ │ ◄── 200 { "status": "done", │ │ "result": "/videos/123/hd" } │ │ ── or, instead of polling ── │ register a webhook URL ─────────►│ server POSTs you when done

This is the 202 Accepted → job id → poll a status endpoint (or receive a webhook) pattern. 202 means "I've accepted the work, it isn't done yet"; the job id is the handle; the client either polls GET /jobs/{id} until done/failed or registers a webhook for push notification. Buys: short, reliable requests; work that survives a slow backend; natural fit for the queue in 11. Costs: more moving parts (a job store, status semantics, webhook retry/verification), and the client must handle "not done yet." Combine with the idempotency key so a retried submit doesn't enqueue the job twice — the exactly-once-across-services concern of 12.

Batch / bulk endpoints: spend one round trip instead of N

The flip side of chattiness: if a client routinely needs 50 related items, making it issue 50 GETs is a round-trip tax (each costs an RTT — recall 02). Offer a batch endpointPOST /photos/batchGet with a list of ids, or GET /photos?ids=1,2,3 — that returns many in one call. Buys: collapses N round trips to one or two; this is one of the cheapest latency wins there is (more in 14). Costs: partial-failure semantics (what if item 3 of 50 fails? — usually a per-item result array with individual statuses, not a single all-or-nothing code) and a cap on batch size to bound the work. The calculator below quantifies exactly how big this win is.

Try it: round-trip & over-fetch calculator

The single most common API-design mistake a newcomer ships is the chatty 1 + N pattern: fetch a list, then loop and fetch each child one at a time. Each call costs a full round trip, and done serially they add up. Move the sliders and watch how a batch endpoint or a single GraphQL query collapses the wall-clock latency.

Round trips → wall-clock latency
Wall-clock ≈ round_trips × RTT (serial). Naive REST = 1 + N calls; batch = 2 calls (list + one batch fetch); GraphQL/single = 1 call. Same data, wildly different latency.
Round trips
Wall-clock latency
Over-fetch
vs naive REST
Verdict

Interview prompts you should be ready for

  1. "Why would you sketch the API before designing the storage or the boxes?" (senior answer: the API is the contract — it pins scope into concrete verbs/nouns, surfaces the hard questions (pagination, freshness, payload size) that drive the data model and caching, and unlike internals it's irreversible once clients depend on it. Internals are cheap to change; the contract is not.)
  2. "A client POSTs a payment, times out, and retries. How do you avoid a double charge?" (senior answer: POST isn't idempotent, so I make it so with an Idempotency-Key header — client sends a UUID per logical op and resends it on retry; the server stores key→response for a dedup window (e.g. 24h) and replays the stored result instead of re-executing. Duplicates are the normal case at scale, not an edge case.)
  3. "Offset vs cursor pagination — which and why?" (senior answer: cursor/keyset by default. Offset scans and discards skipped rows — O(n) deep in the list — and the window drifts when the list mutates, so you re-see or skip items. Cursor anchors to a sort key (timestamp,id), so it's stable under concurrent writes and O(log n) via the index, and it merges cleanly across shards. Always cap page size either way.)
  4. "When would you choose gRPC over REST, and GraphQL over both?" (senior answer: gRPC for internal service-to-service — binary protobuf on HTTP/2, typed stubs, streaming, low overhead — but it's not browser-native and opaque to debug. GraphQL for rich/mobile front-ends fetching nested graphs in one round trip, at the cost of resolver complexity, hard HTTP caching, and the N+1 problem. REST stays best for public, cacheable, human-debuggable CRUD.)
  5. "How do you evolve an API without breaking existing clients?" (senior answer: separate additive (new optional field/endpoint — ship freely) from breaking (remove/rename/retype — needs a new version). Expose the version via URI /v1/ for legibility, lean on the tolerant-reader principle so clients ignore unknown fields, and retire old versions on a published deprecation/sunset schedule rather than never.)
  6. "What does a good error response look like?" (senior answer: a consistent machine-readable envelope — problem+json with a stable error code clients branch on, the numeric status, a human detail, and a request_id for tracing. Pick the right status (400 malformed vs 422 semantically invalid; 401 unauthenticated vs 403 unauthorised; 409 conflict). Never leak stack traces, SQL, or internal hostnames.)
  7. "Design the API for a 5-minute video-transcode operation." (senior answer: don't block the request — return 202 Accepted with a job id, run the work on a queue, and let the client poll GET /jobs/{id} until done/failed or register a webhook for push. Make the submit idempotent so a retry doesn't enqueue twice, and define explicit terminal states.)
  8. "The mobile team says the home screen makes 30 API calls and is slow on 4G. What do you do?" (senior answer: it's the chatty 1+N pattern — wall-clock ≈ round_trips × RTT, brutal on a high-RTT link. Collapse it with a batch endpoint (one call for many ids) or a GraphQL/BFF query that returns exactly the nested fields the screen needs in one round trip; mind partial-failure semantics and a batch-size cap.)
Takeaway

The API is the contract clients code against — design it early, because it's the one thing you can't quietly rewrite. Model resources as nouns and let HTTP's verbs and status codes (200/201/202/204, 400/401/403/404/409/422/429, 500/503) be your vocabulary; keep requests stateless so any box can serve them. Choose the style by the caller (REST public, gRPC internal, GraphQL rich front-ends), and remember every choice names a cost: GraphQL trades client round trips for server resolver complexity. Make unsafe operations safe to retry with an Idempotency-Key, page with cursors not offsets, version additively with a tolerant reader, return machine-readable errors that leak nothing, and push long work behind 202 + a job id. Round trips are latency — batch them. Next we turn the verbs and nouns into tables and indexes in 04 · Data modeling & storage engines.