System Design APIs
REST API Design
You tap Pay $100 at checkout. The phone sends one HTTP request. A second later the network times out. Did the charge go through? If the app retries without care, the user might be billed twice.
That is why design matters. Good APIs name durable , use HTTP verbs correctly, and make retries, pagination, and versioning safe, not just pretty JSON.
What happens on Pay?
POST /charges identifies what you are creating.
Retries reuse the same key so you charge once.
Status + body
201 Created with a stable charge id, or a clear error.
Auth, quotas, and consistent errors keep the API safe.
Before naming endpoints, walk one request end to end so the later checklists have a concrete picture.
What actually happens in a REST call
A resource is the thing the API identifies; a representation is the information sent about it. The same order can have a JSON representation for an app and an HTML representation for a browser without becoming two different orders. An endpoint therefore needs more than a URL: define the accepted input, required permissions, returned fields, error cases, and what successful completion guarantees.
The client talks about a with a URL. /users/123 means "this user," not "run getUser." The HTTP method carries the intent: GET reads, POST creates, PUT replaces, PATCH patches, DELETE removes.
The server returns a representation: usually JSON, plus status codes caches and gateways already understand. That is why payment APIs, developer platforms, and cloud consoles are overwhelmingly REST-shaped: tooling, proxies, and browsers speak HTTP well.
At scale, the hard parts are rarely the first endpoint sketch. They are on writes, for huge lists, versioning without breaking mobile apps, and clear errors with request IDs.
With that mental model, decide when REST is enough and when another style earns its keep. The interview-style comparison lives in How to choose REST, gRPC, or GraphQL below; here is the short version.
How to choose REST (or not)
Prefer when clients need stable, cacheable URLs: public APIs, partner integrations, and CDN-friendly reads. GET plus a clear path is a natural cache key.
If web, iOS, and Android each over-fetch or chain many calls, may help. If traffic is internal and high-QPS, may help. Data model, caching, and fan-out usually matter more than protocol fashion: switch only when a concrete requirement appears.
Rule of thumb: start resource-oriented. Nest owned collections when that is the natural access path, then justify query flexibility (GraphQL), internal throughput or streaming (gRPC), or push events (SSE for one-way server push, WebSockets for bidirectional low-latency sync).
Design an endpoint in 8 steps
- 1. Name the resource with nouns: /orders/{orderId}/items.
- 2. Pick the method: GET read, POST create, PUT replace, PATCH partial update, DELETE remove.
- 3. Define the request body and validation errors.
- 4. Return a small response shape with stable IDs.
- 5. Choose status codes and one consistent error object.
- 6. Decide retry safety: idempotency key for non-safe writes.
- 7. Add pagination, filtering, field selection, and compression.
- 8. Add auth scopes, rate limits, request IDs, and versioning policy.
Once you keep REST, the next design fork is nouns versus verbs in the URL, then how nested ownership shows up in paths.
Resources vs operation-oriented APIs
Resource-oriented (REST default)
models the world as (nouns) you act on with HTTP verbs. Map domain entities to URLs (User → /users), nest owned collections (/users/{id}/posts), and let the method carry intent. This shape is the usual default for public and product APIs.
Operation-oriented (RPC-style)
- Paths name actions like
/getUser, so caching and docs stay harder. - Fits multi-step workflows that do not map cleanly to one resource (approve then notify).
- Prefer a custom method on a resource:
POST /orders/{id}:cancel. - is harder to reason about when every path invents its own verb.
Map entities → resources, then nest relationships
Start from the domain model. Each durable entity that clients address by ID becomes a resource collection. Child or owned collections become nested paths when the relationship is the primary access path.
| Entity / relationship | Resource path |
|---|---|
| User | /users, /users/{id} |
| Posts owned by a user | /users/{id}/posts |
| A specific post (global ID) | /posts/{postId} (also fine) |
| Comments on a post | /posts/{postId}/comments |
| Order line items | /orders/{id}/items |
# Collection + member
GET /users → list users
POST /users → create user
GET /users/{id} → read user
PUT /users/{id} → replace user (idempotent)
PATCH /users/{id} → partial update
DELETE /users/{id} → delete user
# Nested relationship (user's posts)
GET /users/{id}/posts → list posts for user
POST /users/{id}/posts → create a post under that user
GET /users/{id}/posts/{postId} → read one nested post
# Deeper nest when ownership is clear
GET /posts/{postId}/comments
POST /posts/{postId}/comments
GET /orders/{id}/itemsNest when the child is primarily accessed through the parent. Prefer a top-level resource (/posts/{id}) when clients address it by a stable global ID across contexts.
When to reach beyond REST
Teams already know HTTP, status codes, and . JSON is flexible to evolve (add optional fields) but pays a serialization and size cost versus binary formats. Start with REST unless the problem needs query flexibility, streaming, or extreme internal efficiency.
- when many clients need different shapes and under/over-fetching dominates.
- for high-QPS internal service calls with typed stubs and compact payloads.
- SSE (server-sent events): one-way server-to-client stream over plain HTTP.
- WebSockets: bidirectional low-latency sync for chat, collab, or game state.
Payload and bandwidth budget
Header overhead
Assume each request carries 2 KB of headers and the service receives 50,000 requests per second. Multiplying bytes per request by requests per second gives 100 MB/s of request headers alone, using decimal units. This excludes response bodies and lower-level overhead. Reducing a cookie helps repeatedly because the browser may attach it to every matching request.
Field trimming
8KB response trimmed to 800B saves 7.2KB. Across 1B reads, that is about 7.2TB less transfer.
Compression
100KB JSON compressed to 20KB saves 80KB. Across 10M responses, that is about 800GB less egress.
Fan-out
One 600B event to 10K subscribers becomes 6MB of outbound payload, before protocol and encryption overhead.
Endpoints alone are not enough. Clients also need safe retries, pagination, auth, evolution, and debuggable failures.
What a solid API design covers
Design checklist
- Resource names first: nouns, stable IDs, and clear parent-child relationships.
- Method semantics: safe GETs, idempotent PUT/DELETE, for POST retries.
- Pagination and filtering: for feeds, bounded limits, deterministic ordering.
- Contracts: , consistent error shape, request IDs, deprecation and sunset policy.
- Protection: auth scopes, , quotas, validation, and backward-compatible versioning.
Debugging questions
- Is the failure client validation, gateway rejection, auth, upstream timeout, or data conflict?
- Can the client safely retry, or will duplicates create extra side effects?
- Are p95 and p99 slow from payload size, N+1 calls, cache misses, or DB scans?
- Do mobile or browser clients need smaller responses, compression, CORS, or fewer round trips?
- Does the response include a stable error code and request ID support can trace?
How to choose REST, gRPC, or GraphQL
Choose the API style by client type, payload shape, caching needs, and operational maturity.
REST
Best default for browser and public HTTP APIs: cacheable resources, simple debugging, visible URLs, and broad infrastructure support.
gRPC
Best for controlled service-to-service calls: typed contracts, compact binary payloads, deadlines, streaming, and high request volume.
GraphQL
Best when many clients need different nested shapes: fewer round trips, less over-fetching, but harder caching and stricter query governance.
When debugging feels stuck
Split latency into client wait, gateway time, upstream fan-out, serialization, payload size, and cache behavior before changing protocols.
Simple rule of thumb
Start with REST for public clients, gRPC for busy internal RPC, and GraphQL only when nested shape flexibility is worth the caching and governance cost.
Protocol choice settles style. Maturity levels describe how fully you use HTTP's uniform interface, from RPC-over-HTTP up to hypermedia.
REST principles and maturity levels
Stateless
Each request carries all info needed. No server sessions, so any replica can serve any call.
Resource-based
URIs name (nouns): /users/123, not /getUser?id=123.
Uniform interface
HTTP methods (GET, POST, PUT, PATCH, DELETE) define the action with consistent conventions across endpoints.
Cacheable
Responses declare cacheability via headers (Cache-Control, ). At scale, caching can cut backend load sharply (see Numbers to know).
Richardson Maturity Model
Single URI, single method (RPC-style): POST /api { "action": "getUser", "id": 123 }
Multiple URIs (resources), still one method: POST /users/123 { "action": "get" }
HTTP methods + status codes (most APIs here): GET /users/123 → 200 | POST /users → 201
links in responses: "_links": { "posts": "/users/123/posts" }. Adds discoverability; grows payload size.
The checkout timeout in the hero is the classic write hazard. is how clients retry without double side effects.
Idempotency when retries are inevitable
Suppose a client creates an order with an idempotency key and loses the response. The server must associate that key with the caller, the original request, and its recorded result. A retry with the same key and equivalent input returns that result; reusing the key for a different order should be rejected. The key record and order creation need an atomic relationship so concurrent retries cannot both create an order. Define how long keys remain valid and what a client should do after that window expires.
| Method | Idempotent | Safe | Body | Notes |
|---|---|---|---|---|
| GET | YES | YES | No | Always cacheable |
| POST | NO | NO | Yes | Use idempotency keys |
| PUT | YES | NO | Yes | Full resource replace |
| PATCH | NO* | NO | Yes | Partial update |
| DELETE | YES | NO | No | 2nd delete = 404 (OK) |
Solution: Idempotency-Key pattern
POST /v1/charges
Headers: Idempotency-Key: "key_abc123"
Body: { "amount": 10000, "currency": "usd" }
Server:
1. Check Redis for "key_abc123"
YES → return cached response (no re-execution)
NO → process charge → store key + response (TTL: 24h)Lists grow under live traffic. Offsets are simple but drift under inserts; keeps stable iteration.
Pagination: cursor vs offset
A cursor represents a position in a defined ordering, such as creation time followed by a unique identifier to break ties. After returning the first 20 records, the next request asks for records after the last pair it saw. This avoids scanning and discarding a large offset, but does not automatically provide a snapshot: concurrent inserts, deletions, or sort-key changes can still affect the traversal. State whether the API promises a live view or a stable export.
Offset pagination
GET /posts?offset=10000&limit=20
SQL: SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000The DB scans and discards 10,000 rows. Page 500 is much slower than page 1, and inserts between pages cause skips or duplicates.
Cursor pagination
GET /posts?after=post_abc&limit=20
SQL: SELECT * FROM posts WHERE id < 'post_abc' ORDER BY id DESC LIMIT 20Time stays roughly constant per page, items are not skipped or duplicated, and the pattern scales to huge catalogs and feeds.
A few capacity figures show up across rate limits, caching, field masks, and auth. Keep them here and cross-ref instead of restating.
Numbers to know
- Published API quotas vary: Maps ~50K req/day; video platforms ~10K units/day; payment APIs ~100 req/sec.
- Developer APIs often sit near 5K req/hr; social read workloads near 900 reads per 15 minutes.
- Cache-Control and ETag discipline can cut origin load by 90%+ on read-heavy public GETs.
- Field masks: 10 KB → 2 KB (−80%) at 1B reads/day saves about 8 TB of egress per day.
- JWT validation must stay sub-millisecond: short access tokens (~15 min) with longer refresh (~30 days).
Payload trimming math also appears in Payload and bandwidth budget above; field-mask walkthrough is in the wrap-up below.
With lists and writes under control, protect the edge and evolve the contract without bricking mobile clients overnight.
Rate limits, versioning, auth, and gateways
without mystery
Throttle abusive or bursty callers and return clear headers so clients know the limit, remaining budget, and when to retry. Example quotas live in Numbers to know.
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
Retry-After: 30| Algorithm | Description | Used by |
|---|---|---|
| Token bucket | Tokens refill at a fixed rate; allows short bursts. | Cloud platforms, payment APIs |
| Sliding window | Count requests in a rolling window; more precise. | Developer APIs |
| Fixed window | Count per calendar interval; simple but ~2× burst at boundaries. | Simple APIs |
API versioning strategies
Plan how URLs, headers, or content negotiation evolve so pinned mobile clients do not brick overnight.
URL path
/api/v1/users
/api/v2/usersCache-friendly and easy to understand; URLs accumulate versions. Common in payment and social APIs.
Header-based
Accept: application/vnd.api.v2+jsonClean URLs; harder to test from a browser. Common in developer APIs.
Query parameter
/users?version=2Easy to switch; pollutes the query string and complicates cache keys. Some internal APIs.
Backward compatibility: never remove a field or change its type; add new fields as optional; deprecate with a sunset header. Payment APIs often keep clients pinned to the signup-time version for years.
Authentication patterns
Sessions, tokens, OAuth scopes, and gateway enforcement decide who can touch which resources without trusting clients blindly.
API keys
X-API-Key: sk_live_abc123...Simple and easy to revoke; no end-user identity; server-to-server only. Common for payment, email, and messaging APIs.
OAuth 2.0 + JWT
Authorization: Bearer eyJhbG...OAuth 2.0 delegates access; JWT (JSON Web Token) carries claims without a session store. Scoped and stateless; revocation is harder. Common for large public APIs.
mTLS (internal)
Mutual TLS: both client and server present certificates. Strongest zero-trust option; certificate management overhead; not for public browsers. Common in service meshes.
At billion-user scale, cache JWKS (JSON Web Key Set) at the edge and rotate signing keys without downtime. Timing targets are in Numbers to know.
Bulk operations and the API gateway
Batch endpoints and gateways aggregate traffic so microservices do not drown in chatty one-row calls.
Bulk operations (N calls → 1)
PATCH /users/bulk
{ "operations": [
{ "id": 1, "status": "active" },
{ "id": 2, "status": "active" }
] }
Response: { "succeeded": 987, "failed": 13 }API gateway pattern
Client → API gateway → microservices. The gateway typically owns:
- Authentication (JWT validation) so services trust a verified identity.
- per user, IP, or key to protect upstreams.
- Request routing (
/users→ User Service) and GET response caching. - Circuit breaking (fail fast if a service is down) and API version routing.
Contracts and conditional headers finish the public surface: machines share one schema, and writers do not clobber each other.
Contracts, errors, and optimistic concurrency
An error response should tell a client what it can safely do next. Validation errors require changing input; authentication failures require establishing credentials; conflicts require refreshing state; transient failures may permit bounded retries. Keep machine-readable error identifiers stable even when human-readable wording improves. For asynchronous work, return an accepted status and a status-resource identifier instead of implying that the final operation has already completed.
contracts and consistent errors
Machine-readable specs plus a stable error shape let humans and tooling integrate without guessing.
OpenAPI spec
openapi: 3.0.0
paths:
/users:
get:
summary: List users
parameters:
- name: after
in: query
- name: limit (default: 20)Auto-generates docs, SDKs, and mock servers.
Consistent errors
HTTP/1.1 402 Payment Required
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "Card declined.",
"request_id": "req_abc"
}
}Every error: type, code, message, param, doc_url, request_id.
RFC 9457 problem details and OpenAPI 3.1
Prefer application/problem+json with stable fields: type, title, status, detail, instance (RFC 9457). OpenAPI 3.1 aligns JSON Schema 2020-12, supports webhooks, and documents problem responses per status (OpenAPI 3.1).
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/version-conflict",
"title": "Resource version conflict",
"status": 409,
"detail": "Profile was updated by another client.",
"instance": "/users/123"
}openapi: 3.1.0
info:
title: Users API
version: 1.0.0
paths:
/users/{id}:
get:
responses:
'200':
description: OK
'404':
description: Not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'Conditional requests and optimistic concurrency
Two users can read version 7 and then save different edits. If both writes replace the same record unconditionally, the later write silently erases the earlier one. A conditional update makes the version check and update one atomic storage operation. The first update creates version 8; the second fails its version-7 condition and must refresh or merge. Reading a version and checking it only in application memory leaves the race unresolved.
and If-* headers let caches and clients avoid wasted bandwidth and detect lost updates before they corrupt state.
| Header | Use | Typical response |
|---|---|---|
| If-None-Match | Cache revalidation (GET) | 304 Not Modified if ETag matches |
| If-Match | Optimistic concurrency (PUT/PATCH/DELETE) | 412 Precondition Failed if resource changed |
| If-Unmodified-Since | Time-based guard on writes | 412 if modified after timestamp |
If-Match flow: GET /users/123 returns ETag: "v7" → client sends PATCH /users/123 with If-Match: "v7". Server applies the patch only if the version still matches; otherwise 412 and the client refreshes. Defined in RFC 9110 conditional requests.
Resource-oriented design at search scale
Patterns that keep pagination, caching headers, and graceful degradation workable at enormous request rates.
- Resource-oriented: everything has a name, e.g.
//storage.googleapis.com/buckets/my-bucket/objects/cat.jpg. - Standard methods first (List, Get, Create, Update, Delete); custom methods only when those do not fit.
- Long-running ops:
POST … → Operation, then pollGET /operations/op-456. - Field masks save bandwidth (
?fields=name,email); savings in Numbers to know. - -based concurrency:
If-Matchon writes, same rule as above.
Close the lesson by locking the invariant, walking one capacity example and one failure timeline, then practicing and remembering the spine.
Correctness invariant
What must never become false
Unsafe writes with the same idempotency key produce at most one successful side effect, and conditional updates with / If-Match never silently overwrite a newer version (412 or equivalent, then client refresh). Resource URLs stay stable identities; versioning must not invent a second source of truth for the same entity.
Worked example: field masks at 1B requests/day
Average response 10 KB trimmed to 2 KB via field masks (−80%). Saving 8 KB × 1B reads/day ≈ 8 TB less egress per day (same figure as Numbers to know). Same resource; smaller projections. Pair with so deep offset scans do not multiply DB work.
Failure timeline: lost update without If-Match
T0 Client A GET /users/123 → ETag "v7", display name Alice T1 Client B GET /users/123 → ETag "v7", edits email, PUT succeeds → "v8" T2 Client A PUT name=Alicia without If-Match → overwrites email from B T3 User-visible invariant broken: B's write vanished (lost update) T4 Require If-Match: "v7" → A gets 412, refreshes, merges, retries
Optimistic concurrency is not optional once two clients can edit the same resource.
Exercises
Exercise 1
Design POST /charges with idempotency keys. What do you return on a replay after success, after failure, and while the first attempt is still in flight?
Exercise 2
Offset page=50000 of a 10M-row table is slow. Switch to cursor pagination. What breaks for "jump to page 50" UIs, and how do you explain it?
Exercise 3
Pick URI versioning vs header versioning for a public payment API. Name one migration that each makes harder.
Try it: pick the REST status code
Each card is a concrete API call. Select the status you would document for clients, then move through scenarios to build intuition for safe, predictable HTTP APIs.
GET /orders/42 (no body)
- Attempts
- 0
- Correct picks
- 0
- Documented answer
- 200
Choose the HTTP status you would return, then check your answer against REST semantics.
Topic-specific references
- RFC 9110: HTTP Semantics: methods, conditional requests, caching.
- RFC 9457: Problem Details for HTTP APIs: stable machine-readable errors.
- OpenAPI Specification 3.1: contracts, webhooks, and documented problem responses.
What to remember
at scale is about consistency and developer experience. Prefer over offset, use for unsafe writes, version from day one, with clear headers, and ship error shapes that help people debug. Study strong public payment and resource-oriented APIs as living reference implementations.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Design stable resource contracts with correct methods, status codes, concurrency controls, idempotency, pagination, errors, and evolution.
Explain the decision
How do ETags prevent a lost update?
Read the reasoning
The client sends If-Match with the version it read; the server updates only if that version is still current, otherwise returns a precondition failure.
Apply it to a production scenario
Mobile clients retry POST /payments after a timeout. What contract is safe?
Read one approach
Require a scoped idempotency key, fingerprint the request, persist the first terminal response, and return the same result for matching retries.
Work through the numbers
A 1 MiB list response is requested 20,000 times/minute and 70% can return 304. Estimate payload bandwidth saved.
Read the worked calculation
20,000 × 0.70 MiB/min ≈ 14,000 MiB/min, about 233 MiB/s, excluding headers and compression.
Reason through a failure
What must cursor pagination guarantee during concurrent inserts?
Read the recovery reasoning
A stable ordering key and cursor boundary must prevent duplicates or skips according to the documented snapshot/freshness contract.
Sources and further study
Continue with API Design Principles.