API Design
an api is a promise you cannot take back: the moment a second team depends on it, every quirk you shipped is load-bearing. 𐃏 this page is the checklist for making promises deliberately — resources and verbs, status codes, pagination, versioning, errors, auth, and rate limiting, with the http machinery treated as the application-layer protocol it is (Kurose, James F. and Ross, Keith W., 2020).
rest, actually
resources and verbs
rest’s one big idea: model the domain as nouns (resources with urls) and reuse http’s small, fixed verb set — rather than inventing an endpoint per action. 𐃏
| method | safe | idempotent | meaning |
|---|---|---|---|
| GET | yes | yes | read; never mutate on a GET |
| HEAD | yes | yes | GET without the body (cheap existence checks) |
| PUT | no | yes | replace whole resource at this url |
| DELETE | no | yes | remove; second call may 404, state is the same |
| POST | no | no | create under a collection / trigger an action |
| PATCH | no | no | partial update (no idempotency guarantee) |
- safe = no state change (caches and crawlers rely on this); idempotent = \(f(f(x)) = f(x)\) — repeating the call leaves the same end state, which is what makes retries legal. clients and proxies will retry idempotent methods; if your GET mutates, a prefetcher will mutate it for you.
- POST is the escape hatch. for create-with-retry semantics, accept an
Idempotency-Keyheader and dedupe server-side — the same trick as event-driven consumers.
the status codes that matter
return the most specific code you can justify; clients branch on them.
| code | meaning | notes |
|---|---|---|
| 200 | ok | body contains the result |
| 201 | created | set Location: header to the new resource |
| 202 | accepted | async: work queued, poll the job url |
| 204 | no content | success with nothing to say (DELETE, some PUTs) |
| 304 | not modified | conditional GET hit (ETag / If-None-Match) |
| 400 | bad request | malformed syntax, failed validation |
| 401 | unauthenticated | misnamed “unauthorized”: who are you? |
| 403 | forbidden | i know who you are; the answer is no |
| 404 | not found | also the polite mask for 403 when existence is secret |
| 409 | conflict | version clash, duplicate create |
| 422 | unprocessable content | syntactically fine, semantically wrong |
| 429 | too many requests | rate limited; send Retry-After |
| 500 | internal server error | your bug; log it, never leak the stack trace |
| 503 | service unavailable | overload/maintenance; also deserves Retry-After |
two habits worth the pedantry: distinguish 401 (authenticate) from 403 (authenticated but denied), and never return 200 with {"error": ...} in the body — it defeats every generic client, cache, and monitor between you and the caller.
url conventions
- plural nouns for collections, ids for members:
/orders,/orders/42,/orders/42/items. - no verbs in paths — the verb is the method.
POST /orders/42/cancelis the pragmatic exception when “cancel” is a genuine domain action that PATCH can’t express cleanly; model it as creating a cancellation resource if you want to stay pure. - lowercase, hyphens not underscores, no trailing slash, no file extensions; filtering/sorting/searching live in the query string:
/orders?status=open&sort=-created_at. - nesting: one level deep is navigation, two is a smell —
/customers/7/orders/42/items/3couples the url to a hierarchy that will change; give items their own top-level identity.
pagination
never return an unbounded collection; the question is only which cursor you hand back.
- offset:
?limit=50&offset=100. trivial to implement (LIMIT/OFFSET), supports “jump to page 7”. two real problems: the database must still count and skip 100 rows (cost grows linearly, so deep pages get slow), and concurrent inserts/deletes shift the window — page 3 shows rows you already saw on page 2, or silently skips some. - cursor (keyset):
?limit=50&after=eyJpZCI6OTgxfQ— an opaque token encoding the last-seen sort key; the query becomesWHERE (created_at, id) < (…) ORDER BY created_at DESC, id DESC LIMIT 50, an index seek at constant cost, stable under writes. the price: no random access, and the sort order is baked into the token. - default: cursor for anything user-facing or large; offset is fine for small admin tables. either way return the next cursor in the response (and treat it as opaque — clients that parse your cursor format have just extended your public api).
versioning strategies, honestly
| strategy | example | for | against |
|---|---|---|---|
| uri path | /v2/orders | visible, curl-able | one bump forks every resource |
| custom header | Api-Version: 2024-06-01 | urls stay clean | invisible in logs, forgettable |
| media type | Accept: ...vnd.co.v2+json | the “correct” answer | tooling makes it unpleasant |
| dated versions | stripe-style pinning | gradual migration | a translator per date, forever |
the honest advice:
- versioning is a defeat you plan for, not a feature. spend the effort on additive evolution first — new optional fields and endpoints break nobody, and clients must be written to ignore unknown fields (the same tolerant-reader rule as event schema evolution).
- when you must break:
/v2in the path is the least-clever option and therefore the best default — visible, testable, obvious in every access log. - version bumps are migrations, not releases: you will run v1 and v2 side by side for years; deprecation needs headers (
Deprecation,Sunset), metrics on v1 traffic, and emails to the last three laggards. budget accordingly.
error design
errors are part of the contract — clients write code against them.
- use problem+json (rfc 9457, formerly 7807): a standard error envelope with media type
application/problem+json:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "insufficient funds",
"status": 422,
"detail": "balance is 30.00, transfer wanted 120.50",
"instance": "/transfers/7f3c",
"balance": 30.00
}
typeis a stable identifier clients can branch on (the url need not resolve, but it’s polite if it documents the error);detailis for humans; extra members (likebalance) are allowed and encouraged.- return validation failures in bulk — an
errorsarray with one entry per field — not one 400 per round trip. - never leak internals: stack traces, sql fragments and hostnames in error bodies are a gift to attackers and a lawsuit to you.
auth
- api keys: a static bearer secret identifying a machine caller. fine for server-to-server integrations and internal tooling. notes: transmit only over tls in a header (never the query string — urls end up in logs and browser history), store hashed like passwords, support rotation with overlapping validity, and scope each key to the minimum surface.
- oauth2: a delegation protocol — the user grants your app limited access to their resources without sharing a password. the flow that matters today is authorization code + pkce (browser redirects to the provider, app exchanges a one-time code for tokens; pkce binds the code to the initiating client so an intercepted code is useless). client credentials covers machine-to-machine; the old implicit and password flows are deprecated — do not build new systems on them.
- jwt: a token format, not a protocol — a signed (not encrypted) json payload of claims. the win: stateless verification, any service can check the signature locally without a session-store round trip. the costs: revocation is hard (tokens are valid until
exp, so keep lifetimes short — minutes — and pair with refresh tokens), clock skew matters, and the payload is readable by anyone who holds it. security notes: verifyalgagainst an allowlist (thealg: noneand rs256-to-hs256 confusion attacks are real and historic), validateiss,audandexpalways, and never put secrets in claims.
rate limiting
protects you from abuse, bugs, and enthusiastic customers — and protects tenants from each other.
token bucket
the standard algorithm because it permits bursts while capping the average: a bucket holds at most \(b\) tokens, refilled at \(r\) tokens per second; each request spends one. sustained throughput converges to \(r\), and a client that has been quiet can burst \(b\) requests at once. 𐃏 over any window of length \(t\) the bucket admits at most \(b + rt\) requests.
lazy-refill implementation (no background thread — tokens are computed from elapsed time on each call), driven with a fake clock at 2 tokens/s, capacity 5:
class TokenBucket:
"""capacity b tokens, refill rate r tokens/second, lazily refilled."""
def __init__(self, rate, capacity, clock):
self.rate, self.capacity, self.clock = rate, capacity, clock
self.tokens, self.last = capacity, clock()
def allow(self, cost=1.0):
now = self.clock()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False
# fake clock: 2 tokens/s refill, burst capacity 5
now = [0.0]
tb = TokenBucket(rate=2.0, capacity=5, clock=lambda: now[0])
# a burst of 7 requests at t=0, then one request every 0.25 s
times = [0.0] * 7 + [1.0 + 0.25 * i for i in range(8)]
for t in times:
now[0] = t
verdict = "200" if tb.allow() else "429"
print(f"t={t:5.2f}s tokens_left={tb.tokens:4.2f} -> {verdict}")
t= 0.00s tokens_left=4.00 -> 200
t= 0.00s tokens_left=3.00 -> 200
t= 0.00s tokens_left=2.00 -> 200
t= 0.00s tokens_left=1.00 -> 200
t= 0.00s tokens_left=0.00 -> 200
t= 0.00s tokens_left=0.00 -> 429
t= 0.00s tokens_left=0.00 -> 429
t= 1.00s tokens_left=1.00 -> 200
t= 1.25s tokens_left=0.50 -> 200
t= 1.50s tokens_left=0.00 -> 200
t= 1.75s tokens_left=0.50 -> 429
t= 2.00s tokens_left=0.00 -> 200
t= 2.25s tokens_left=0.50 -> 429
t= 2.50s tokens_left=0.00 -> 200
t= 2.75s tokens_left=0.50 -> 429
read the trace: the cold bucket absorbs a 5-request burst, rejects the 6th and 7th; afterwards the client offers 4 requests/s against a 2/s refill and gets exactly every other one — the long-run rate is \(r\), as designed. production notes: key buckets per api-key or per user and per ip, keep them in redis (one EVAL for atomicity), and always send 429 with Retry-After plus the draft RateLimit-* headers so well-behaved clients back off — see the retry-with-jitter discussion in microservices for the client side.
the request’s whole life
contract-first with openapi
- write the openapi (swagger) document before the handlers: the spec is the contract, reviewed like code, and everything else is generated from it — server stubs, typed clients, docs, request validators, and contract tests in ci.
- the alternative (annotations generating the spec from code) drifts exactly when you are busiest; a handwritten spec that ci enforces cannot drift silently.
- the same discipline that testing calls design-by-contract, applied at the network boundary.
when not rest
- grpc: internal service-to-service where you own both ends — typed protobuf contracts, http/2 streaming, an order of magnitude less serialisation overhead than json. wrong for public browser-facing apis (tooling, debuggability, proxies).
- graphql: many heterogeneous clients composing views over a rich graph (the classic: mobile apps that can’t afford six round trips). you trade the n+1 problem, per-query cost analysis and cache complexity for client-shaped responses. wrong as a default for simple resource crud — you inherit its whole complexity budget on day one.
- webhooks: the api calling you — reversed direction, push not poll. design notes: sign every delivery (hmac over body + timestamp header; receivers must verify both, the timestamp kills replay), deliver at-least-once with exponential backoff so receivers must be idempotent, and give subscribers a dead-letter view of failed deliveries. the event-carried counterpart of everything in event driven.
see also
- microservices — gateways, retries and the resilience machinery around these contracts
- event driven — the asynchronous complement: events, schemas, idempotency
- internet networks — tcp, tls and http from the wire up
- design principles — interface segregation and least knowledge, at network scale
References
Kurose, James F. and Ross, Keith W. (2020). Computer Networking: A Top-Down Approach, Pearson.
Backlinks (4)
1. Event Driven /wiki/se/architecture-design/event-driven/
event-driven architecture inverts the direction of knowledge: instead of the caller knowing who must react, the reactor knows what it cares about. producers announce facts; consumers subscribe. the whole style is the observer pattern with a broker in the middle and a network underneath — which is exactly where the interesting failure modes come from.
events, commands, queries
three message species, constantly confused:
- event: a fact about the past, named in past tense —
OrderPlaced,PaymentFailed. immutable, owned by the producer, zero expectation about who (if anyone) reacts. broadcasting is safe because nothing is being asked. - command: a request for the future —
PlaceOrder. imperative mood, addressed to exactly one handler, can be rejected. commands express intent; events record outcome. compare the command pattern, which reifies exactly this. - query: a question, side-effect free, wants an answer now.
the classic smell is the command in event’s clothing: SendWelcomeEmailRequested published as an “event” that exactly one consumer must process, or the producer silently depends on a specific reaction — you have built rpc with extra steps and none of rpc’s error handling.
𐃏
2. Microservices /wiki/se/architecture-design/microservices/
a microservice architecture decomposes one application into many independently deployable services, each owning its own data, talking over a network. the honest framing: you are trading a code organisation problem for a distributed systems problem. 𐃏 that trade is sometimes worth it. it is not worth it nearly as often as conference talks suggest.
the trade, not the hype
what you actually buy
- independent deployment: team a ships without waiting for team b’s release train. this is the real prize — everything else is secondary.
- independent scaling: scale the search service to 40 replicas while billing idles on 2.
- fault isolation: a memory leak in recommendations does not take down checkout — if you also build the resilience machinery below. isolation is earned, not free.
- technology heterogeneity: the ml service can be python while the ledger is jvm. (also a curse: n stacks to patch.)
- organisational scaling: conway’s law working for you — service boundaries that mirror team boundaries let teams own code end to end.
what you actually pay
- every in-process function call that crosses a new service boundary becomes a network call: it can now fail independently, time out, arrive twice, or arrive late.
- transactions that were a single
BEGIN...COMMITbecome sagas (below) — you give up atomicity across boundaries and must design compensation by hand. - refactoring across service boundaries is an order of magnitude harder than moving code between modules — the boundary is now an api contract with independent release cadences (Fowler, Martin, 2018).
- operational surface explodes: per-service ci, dashboards, alerts, on-call, versioned contracts, backwards-compatible migrations.
when a modular monolith wins
- a modular monolith — one deployable, strictly enforced internal module boundaries, one database with schema-per-module discipline — captures most of the design benefit at a fraction of the operational cost.
- you get cheap refactoring while the domain boundaries are still wrong (they always start wrong), real stack traces, local transactions, and one thing to deploy.
- the sane migration path is monolith-first: find the boundaries by living with them in-process, then extract the one or two services that genuinely need independent deployment or scaling — the strangler fig approach of routing traffic incrementally to extracted pieces. 𐃏
- rule of thumb: if one team can hold the whole system in its head and deploys are not blocked on other teams, microservices solve a problem you do not have.
decomposing a system
bounded contexts (ddd-lite)
the useful sliver of domain-driven design:
3. Wiki /wiki/
Knowledge is a paradox. The more one understand, the more one realises the vastness of his ignorance.