Learn API Design
Start from the consumers and the requirement, model the resources and their states, choose a style with named trade-offs, then design the contract clauses — errors, idempotency, pagination, versioning — that keep it predictable and evolvable.
An API is a behavioral contract, not a list of endpoints. Requirements before endpoints, consumer tasks before resources, granularity, ownership — and why internal APIs still need contracts.
An API is a behavioral contract, not a list of URLs. The contract includes shapes, errors, retry behavior, ordering, consistency and rate limits — everything a client is forced to assume, whether you documented it or not.
The first API artifact should be a list of questions answered, not a list of routes. Who consumes it, what can be retried, what is destructive, and what must stay compatible — endpoints fall out of those answers.
APIs exist for consumer tasks, not for the provider's data model. A mobile dashboard, a partner's invoice integration and an internal inventory call want different granularity, different fields and different guarantees from the same domain.
Too fine and every task takes ten round trips; too coarse and every call hauls a kitchen sink. Granularity is a per-consumer decision, and the network — not aesthetics — is what punishes getting it wrong.
The difference is not importance — it is who absorbs the cost of change. Public APIs trade evolution speed for a long compatibility promise; internal APIs may iterate faster only while every consumer is known and reachable.
Every API needs an owner, a version, a consumer list, an SLO and a deprecation status that a stranger can find in one place. An API nobody owns is a contract nobody keeps.
Consistency, predictability, explicitness, least surprise, good defaults, bounded operations. Principles earn their place as tie-breakers and review questions — not as absolutes that override a requirement.
Everything-POST, verb explosion, raw DB models as contracts, unbounded lists, 200-for-everything, frontend-only authorization, versioning nothing or versioning everything. Recognizing the pattern is faster than rediscovering the pain.
From domain to resources, from resources to operations. Resource vs action, state machines with explicit transitions, backend-for-frontend, and composition — without REST dogma.
Resources are the nouns your consumers need to point at — not your tables, not your classes. Deriving /users, /projects, /memberships and /invitations from one requirement shows the reasoning; the paths are just the residue.
POST /cancelOrder, POST /orders/{id}/cancellations, PATCH {status: "cancelled"} — three shapes for one operation, each promising something different. Actions with their own data and lifecycle are domain concepts worth modeling; the rest can stay verbs or field updates.
An order moves created → paid → processing → shipped → delivered, and not one step in any other order. If the contract does not say which transitions exist, every consumer invents its own machine — and the server enforces a third one.
PATCH {status: "shipped"} makes the client the owner of the machine; POST /orders/{id}/ship makes the server own it. Command-style transitions carry data, enforce guards, and answer retries — at the cost of one endpoint per transition.
CRUD describes storage, not behavior. Payments, approvals, workflows and agent runs have states, guards and side effects that create/read/update/delete cannot say — flattening them into updates hides exactly the semantics consumers must know.
A BFF is an API whose consumer is one client experience: the web app or the mobile app, not "clients in general". It buys screen-shaped responses and per-client iteration speed, and costs an extra service per client type — a price not every team should pay.
An endpoint that answers by calling four other services inherits four latencies, four failure modes and four teams' release schedules. Composition buys consumers one call instead of N — and the contract must say what happens when one of the N fails.
Methods as promises: safety, idempotency, and what retries, proxies and caches are allowed to assume. Status codes that mean something, conditional requests, and caching as part of the contract.
Safe means "calling this changes nothing"; idempotent means "calling this twice equals calling it once". Retrying clients, proxies, caches and crawlers all act on those promises without asking — which is why breaking them breaks things you have never heard of.
GET promises that reading changes nothing — a promise browsers, caches, crawlers and prefetchers spend billions of requests a day relying on. GET /deleteUser?id=42 is not a style violation; it is an open invitation to every robot on the internet.
POST is HTTP's "here, process this" — creation, commands, complex reads, batch submissions. Its defining property is what it refuses to promise: idempotency. Every POST that matters needs an answer to "what if this arrives twice?", because it will.
PUT replaces the whole representation and is idempotent by construction; PATCH applies a partial change and is only as safe as your merge rules. The hard part is not choosing between them — it is saying what null means, and what absent means.
Hard delete, soft delete, async purge — three different promises hiding behind one method. DELETE is idempotent (the retry that gets 404 still succeeded), but what deletion *means* — recoverable? invisible? eventually erased? — is a domain contract HTTP cannot write for you.
The first digit answers "who acts next?" — that is the real contract. You need the dozen codes clients actually branch on, used honestly, far more than you need the other forty memorized.
One mechanism, two superpowers: If-None-Match turns repeat reads into 200-byte 304s, and If-Match turns racing writes into honest 412s. The validator — the ETag — is a contract about when a representation counts as changed.
Cache-Control is not a performance knob — it is a promise about staleness: who may store this response, for how long, and what "fresh enough" means. The most expensive header in HTTP is the one that let a shared cache store a private response.
REST, RPC, gRPC and GraphQL as tools with prices, not religions. What each buys, what each costs operationally, and how consumer environment decides — never “which one is best”.
REST, RPC/gRPC, GraphQL, SSE, WebSockets, webhooks, async jobs — seven shapes, each answering a different question about who the consumer is and how data needs to move. The decision is made by consumer environment and operational budget, not by fashion.
REST is a set of constraints — addressable resources, uniform methods, representations, statelessness, cacheability — that let the whole HTTP ecosystem work for you unmodified. It is not "CRUD over HTTP", and its value is in the guarantees, not the URL aesthetics.
Contorting every operation into one interpretation of REST hides domain semantics behind status flips and produces contracts nobody can read. Clarity and domain meaning outrank purity — and so does the opposite ditch, where "REST is limiting" excuses a verb for everything.
RPC contracts are lists of operations — `UserService.GetUser`, `InventoryService.ReserveInventory` — rather than resources with methods. When the domain is a set of commands between services, that is clearer than bending them into nouns; it costs caching, discoverability and verb discipline.
gRPC is RPC with a schema language (protobuf), generated clients, binary framing and four call shapes including streaming, on HTTP/2. It buys enforced contracts and efficient internal traffic; it costs browser friendliness, readability, and a proto discipline that decides whether evolution is safe.
GraphQL replaces many endpoints with one typed schema that clients query for exactly the fields they need: queries, mutations, subscriptions, resolvers behind each field. The benefits — no over/under-fetching, one contract for many client shapes, introspection — are real; so are the costs, which get their own lesson.
The flexibility GraphQL gives clients is exposure the server must manage: resolver N+1, arbitrary expensive queries, per-field authorization, lost HTTP caching, invisible operations. Batching, cost limits, persisted queries and operation-level telemetry are the price — budget it before adopting the schema.
Required vs optional vs null, response models that are not database rows, over- and under-fetching, batch endpoints, size limits, and file uploads that bypass the API for the bytes.
A request schema is a set of promises about what the server will accept and what each field means. Required vs optional vs nullable, "not sent" vs "sent as null", enums, defaults and the unknown-field policy decide whether the contract can grow — or whether every addition breaks someone.
Serializing the ORM model is the fastest way to ship an endpoint and the most expensive way to own one. A response model is a purpose-built shape — stable ids, explicit types, computed fields, nothing accidental — that lets the table change without the contract noticing.
GET /users/42 returns 80 fields when the screen needs 3; the dashboard makes 8 calls to render once. Both are granularity mismatches between one generic contract and many specific consumers — and the fixes (sparse fieldsets, expansion, GraphQL, a BFF) each move the cost somewhere else.
A client that needs 500 resources can make 500 requests or one. The batch endpoint saves round trips and rate-limit budget — and forces the contract to answer questions a single request never asked: what if item 217 fails, is anything rolled back, and how many requests did that just cost?
Every API has limits on body size, array length, string length, query complexity and file size. The only question is whether the contract states them — with a status code and the number — or whether a load balancer, a JSON parser or the OOM killer states them for you.
The API that handles JSON should not be the pipe for a 3GB video. Create an upload resource, hand the client a signed URL to object storage, confirm completion, then process asynchronously — a four-step contract that keeps the API small, the bytes off your workers, and retries safe.
A consumer who has learned one endpoint should be able to predict every other. Casing, id formats, timestamps, money, envelopes, error shapes and header names are decided once, written in a style guide and enforced by a linter — because consistency is the cheapest documentation an API will ever have.
A stable error contract: machine-readable codes, a taxonomy clients can branch on, field-level validation feedback, explicit retryability, and honest partial-failure semantics.
A failing response is still a response, and clients write code against it. A stable error model — machine-readable code, human message, request id, structured details — is a contract clause, not a courtesy.
Validation, authentication, authorization, not-found, conflict, rate-limit, dependency, internal: eight categories with different owners, different fixes and different retry rules. Collapse them and every client guesses; distinguish them and clients can be correct.
Reject invalid input at the boundary, and say exactly which field failed which rule — all of them, in one pass. A validation error is a collaboration with the caller; "bad request" is a verdict nobody can act on.
Every error answers a question the client is definitely asking: do I try again? A contract that states retryability explicitly — status semantics, Retry-After, a retryable flag — replaces a thousand guessed retry loops with one correct one.
A batch request where some items succeed and some fail has no honest single status code. The contract must choose — atomic, best-effort with a per-item report, or a mix — and say so before the first consumer assumes the wrong one.
Every list endpoint is a query API. Offset vs cursor under concurrent writes, stable ordering, filter allowlists, search as a different contract — and the index each promise requires.
Every list endpoint needs an answer to "and then what?" before the collection grows. Offset, cursor and keyset pagination are different promises about consistency, cost and navigation — and the consumer's access pattern picks, not fashion.
`?page=3&limit=50` is the easiest pagination to build and consume, and it makes two quiet promises it cannot keep at scale: that deep pages are as cheap as shallow ones, and that page boundaries hold still while the collection changes.
A cursor is the server saying "resume after this row" in a token the client stores but never reads. Done right it makes deep traversal flat-cost and write-stable; done lazily it leaks internals, breaks on deploys, and quietly becomes offset with extra steps.
Every filter parameter is a promise that a class of database queries will stay fast forever. Explicit, typed, allowlisted filters keep that promise affordable; a generic query language hands your query planner to strangers.
An ORDER BY in the contract is two promises: that the ordering is affordable, and that it is deterministic. Skip the tiebreaker and pagination corrupts; allowlist nothing and every column is an index you owe.
Filtering promises the exact subset matching a predicate; search promises the most *relevant* results for an expression of intent. Different guarantees, different cost model, different pagination — pretending one is the other breaks both.
GET /orders returning "all of them" works flawlessly until the collection grows — then it fails everywhere at once, and the fix is a breaking change to every consumer. The bound you did not design is the outage you scheduled.
The network loses responses, so clients retry. Idempotency keys, dedup vs idempotency, optimistic concurrency with versions, lost-update prevention, and consistency the contract admits to.
A response can be lost after the server did the work, so every client will eventually retry a request that already succeeded. Idempotency is the contract property that makes that retry safe — and money paths without it double-charge.
A client-generated key turns "did my POST land?" into a question the server can answer: check the store, replay the saved result or process and save. The hard parts are scope, expiry, parameter mismatches, and two identical requests in flight at once.
Idempotency makes a repeated request produce the same outcome and hands that outcome back. Deduplication detects that a message was already seen and drops it. Related, frequently confused — and each one fails when asked to do the other's job.
Let concurrent writers proceed without locks, but make every update state which version it read. A stale version gets a 409 or 412 instead of silently destroying someone else's write — and the contract must say who untangles the conflict.
A reads v1, B reads v1, A writes, B writes — and A's change is gone without an error, a log line, or a conflict. The anatomy of the most silent data-loss bug an API can have, and what a version check turns it into.
A client POSTs a resource, then GETs it — and gets a 404. Nothing is broken unless the contract said otherwise. Read-after-write, monotonic reads and staleness bounds are promises consumers build UI and logic on, so they must be written down.
One request that charges payment, reserves inventory and books shipping cannot be atomic — the ACID boundary died at the first network hop. What replaces it is a contract that models the in-between states: workflows, state machines and compensation.
A timeout is not a failure — it is the absence of an answer. The contract owes clients the missing half of their retry loop: what is retryable, how long to wait, how to back off, and what the server will do to protect itself when everyone retries at once.
When request/response stops fitting: WebSocket message contracts, SSE, streaming, the async job pattern for long-running work, and how completion actually reaches the client.
A WebSocket gives you a pipe, not a protocol. Everything HTTP provided for free — operations, status codes, request/response pairing — you must now design: typed message envelopes, acks, errors, sequence numbers, and a reconnect story clients can actually implement.
One long-lived HTTP response, streaming events one way: server to client. SSE buys auto-reconnect with built-in resume (Last-Event-ID) for the price of unidirectionality — and for notifications, progress, dashboards and token streams, one way is all you needed.
A streamed response is a sequence of commitments, not one answer. The contract must say what each chunk means, whether early chunks can be trusted before the end, how the stream announces failure mid-flight, and what a consumer resumes after a drop.
A request that takes 15 minutes cannot pretend to be request/response — some timeout between the client and your handler will fire first, and a retry starts the 15 minutes again. Return 202 with a job resource instead, and the operation becomes observable, retry-safe and cancellable.
POST the operation, get 202 and a job resource, let a worker do the work, poll or be notified, fetch the result. The pattern is simple; the contract is not — queued/running/succeeded/failed/cancelled is a state machine with retention, cancellation, progress and idempotent creation that consumers build whole workflows on.
Polling, webhooks, SSE/WebSocket, push notification — four ways to say "done", each with a different latency, infrastructure cost, client requirement and duplicate story. Polling with Retry-After is the documented baseline every client can use; the others are upgrades for specific consumers.
A streaming or download API produces bytes faster than some consumer can take them. Where do the bytes wait, who runs out of memory first, and when does the server hang up? A contract that does not answer those questions answers them in production — usually by the whole tier falling over together.
Your contract running against someone else’s server: delivery states, retries, duplicate events, ordering you must not assume, and the signature that makes any of it trustworthy.
A webhook flips the roles: the provider becomes the client, calling an endpoint the consumer operates. Delivery is asynchronous and at-least-once, so the event envelope — event id, delivery id, type, timestamp — is what makes the stream usable, not the payload.
Every event delivery is a little state machine: queued → attempting → delivered, or failed → retrying → dead. The retry schedule, the definition of "delivered", and the dead-letter escape hatch are contract clauses both sides build against.
The provider promised at-least-once, so duplicates are not a bug — they are scheduled. Exactly-once processing is an illusion the consumer manufactures locally: record the event_id, process each id exactly once, and make the recording atomic with the effects.
Retries, parallel dispatch and redrives mean events arrive in whatever order the network permits — `order.shipped` before `order.paid` is routine. Consumers that apply event payloads as state, in arrival order, corrupt their data; the contract must say so and give them a defense.
A webhook receiver is an unauthenticated public POST endpoint that triggers business logic — unless the contract says how events are signed, how timestamps bound replay, and how secrets rotate. Signature verification is the consumer's only proof that an event is yours.
Where authentication and authorization live in the contract: token placement, resource-level permission design, scopes, API keys, rate limits and quotas as documented behavior.
The contract does not implement authentication — it states which credential each consumer type presents, where it rides, how long it lives, and exactly what a 401 means. The mechanisms are Security Engineering's domain; the promises are yours.
Every operation needs a documented answer to "who may call this?" — and the enforcement must check the *object*, not just the endpoint. Missing object-level checks are the most exploited API flaw in the wild, and the contract decides whether denial reads as 403 or 404.
A scope caps what a credential may ask for — `projects:read` cannot touch billing even if the user behind it can. Too coarse and every integration holds admin; too fine and nobody can predict which scope an endpoint needs. The catalog is the contract.
An API key identifies an application — which makes it the natural unit for scoping, rate limiting and metering, and the wrong tool the moment a user is delegating access. Keys are credentials: prefixed, hashed at rest, scoped, and rotatable without downtime.
Every API has a rate limit — the only question is whether it is a documented 429 with headers or an undocumented collapse. The contract names the dimensions (per key, per user, per endpoint class), the numbers, and exactly how a well-behaved client should respond.
A rate limit protects the platform second by second; a quota is an entitlement over a billing period. 100 requests/second and 1M requests/month are different promises with different rejections, resets and communication duties — conflating them breaks both.
The longest-lived part of the contract. Additive change, enum evolution, deprecation as a process, consumer telemetry before removal, schema-first vs code-first, docs and SDKs.
URI versions, header versions, date-pinned versions, or no versions at all — the strategies differ less than the arguments suggest. What matters is what a version promises, what minting one costs, and why additive evolution is the strategy every good API uses between versions.
The safe list and the breaking list are shorter and stranger than intuition says. Adding an optional field is safe; making an optional field required is not; tightening validation, changing a default, or changing what a value means breaks clients without touching a single field name.
You add `suspended` to a status enum — additive, surely safe. Every old client that switched exhaustively over the closed set now throws, hides the record, or worse, treats it as `active`. Enums are the sharpest edge of compatibility, and the fix is a contract clause, not a code change.
Addition is a deploy; removal is a program. Introduce the replacement, measure who still reads the old field, deprecate it visibly, run a real migration window, and remove only when telemetry — not hope — says zero. The steps are boring; skipping any of them is an outage.
Marking something deprecated changes nothing; deprecation is a campaign with artifacts — announcement, migration guide, machine-readable signals, telemetry, a deadline someone will enforce — and a finish line. A deprecation nobody plans to complete is just an apology in advance.
"Can we remove this?" is a telemetry query, not a debate. Per-consumer, per-field usage attribution turns evolution decisions from opinions into evidence — and the 12% of mobile users on an old build stop being invisible exactly when you can count them.
Every breaking change, whatever its label, runs the same program: ship the new surface, support both, move consumers with telemetry and deadlines, deprecate, remove. The compatibility matrix — which client works against which API — is the map; the burn-down is the engine.
Whether the contract file or the handler code comes first matters less than which one is the enforced source of truth. Schema-first buys review-before-build and cross-team parallelism; code-first buys iteration speed; drift — where the served API and the described API diverge — is the failure mode both must engineer away.
OpenAPI captures paths, operations, schemas and security schemes in a machine-readable file — which earns you linting, diffing, mocks, generated clients and always-current reference docs. What it cannot capture is most of what this domain teaches: guarantees live in prose, and the spec is the skeleton they hang on.
For every consumer you never meet, the docs are the API. What must be documented is exactly what consumers are forced to assume — auth, errors, pagination, rate limits, idempotency, guarantees — and the examples are the most-executed code you ship. Undocumented behavior gets reverse-engineered and depended on anyway.
`payments.create({...})` versus hand-rolled HTTP is the visible part. The invisible part is what the SDK owns on behalf of every consumer — retries with idempotency keys, pagination iterators, typed errors, timeouts — and how it is built to survive the API evolving underneath it. A strict SDK turns your safe changes into their crashes.
The API-shaped levers: payload size, compression, request count, caching. Request IDs, metrics without high-cardinality labels, logs that never contain tokens, and contract tests.
Most API latency is decided by the contract, not the code: how many round trips a task needs, how many bytes each carries, and how often a request can be skipped entirely. The levers are payload, compression, request count, caching, serialization and field selection.
Payload cost is paid four times — transfer, serialization, memory, client parse — and it scales with every caller. A 20KB response is a non-event, 200KB is a tax on every mobile render, and 5MB is an architecture mistake wearing a JSON costume.
gzip or brotli shrinks JSON 5–10× for a CPU price paid on every request. The trade inverts on small payloads, already-compressed data and CPU-bound services — and the negotiation headers are contract clauses, not transport trivia.
One opaque id, minted at the edge, propagated through every hop, returned in every response — especially errors. It is the difference between "can you send a screenshot?" and finding the exact failing request in one query.
Four signals per endpoint — request rate, error rate by class, duration percentiles, payload sizes — labeled by route template, method and status class. The craft is in the labels: one high-cardinality label like user_id can melt the metrics system that was supposed to watch everything else.
One structured line per request: operation, status, duration, request id, principal, safe context. The hard part is the discipline of absence — no tokens, no passwords, no full bodies — because logs are the widest-read, longest-retained copy of your traffic.
Unit tests prove the handler works; contract tests prove the promises hold; compatibility tests prove yesterday's consumers survive tomorrow's deploy. An API test suite is organized around the guarantees, and the cheapest test that catches each broken guarantee wins.
Authentication verification, rate limits, size caps, request IDs, TLS, routing and version steering can live at the gateway — one enforcement point instead of N reimplementations. The discipline is knowing which contract clauses belong at the edge, and remembering that gateway-generated responses are part of your contract too.