API Design
API design is the work of shaping the surface other people build against. The useful frame is a single line: above it is the contract, which consumers depend on and you cannot change alone; below it is implementation, which you can rewrite on a Tuesday. Most of what makes an API painful to live with is a decision that ended up on the wrong side of that line.
- Specified with
- OpenAPI
- Error format
- RFC 9457
- Best version bump
- The one you avoid
The contract and the implementation
An API is a promise about how something behaves, made to people who are not in the room and cannot be asked to redeploy on your schedule. That last clause is the whole difficulty. Internal code can be refactored whenever you understand it better; an API cannot, because someone built a payroll integration against it eighteen months ago and has not thought about you since.
So the first design activity is deciding what is in the contract at all. Everything you expose becomes something you have promised, whether or not you meant to: a field returned "just in case" will be parsed by somebody, and removing it will break them. The discipline is to expose the smallest surface that lets consumers do their job, and to say explicitly what is not part of the contract.
Resources, methods, status codes
The convention most HTTP APIs follow is to name things — nouns — and to use the method to say what is being done to them. /orders/42 is a thing; GET, PUT and DELETE are what you do to it. The alternative that creeps in is verbs in the path — /getOrder, /createOrderV2, /cancelOrderReally — which works, and gives up every convention a client library, a cache and a proxy already understand.
Two properties of the methods are worth knowing precisely, because retries depend on them. A safe method does not change anything — GET and HEAD — so a client, a proxy or a crawler may call it freely. An idempotent method can be called repeatedly with the same effect as calling it once: PUT and DELETE are, POST is not. That is why a network failure on POST is genuinely ambiguous and why anything that creates or charges needs an idempotency key.
Status codes are the part everyone half-knows. The classes carry the meaning: 2xx it worked, 3xx look elsewhere, 4xx the caller made a mistake and repeating it unchanged will not help, 5xx we made a mistake and retrying might. Getting the class right matters more than picking between two plausible codes inside it, because clients, load balancers and retry libraries all branch on the class.
Errors and pagination
An error response has two audiences with opposite needs. A human debugging at 2am wants a sentence explaining what went wrong; a program wants a stable token it can branch on. Give both, and keep them separate: a machine-readable code that never changes wording, and a human-readable message that is free to.
There is a standard shape for this so you do not have to invent one: RFC 9457 problem details, served as application/problem+json, with a type identifying the error class, a short title, the status, a detail for this occurrence and an instance. Using it means client libraries can parse your errors without special-casing your API, and it removes the most tedious argument in any API review.
Pagination has one decision in it and most APIs get it wrong. Offset pagination — ?page=3&size=20 — is easy to build and easy to reason about, and it breaks under exactly the conditions real data has: if a row is inserted while a client is on page three, one item shifts to page four and is never seen, or is seen twice. It also gets slower the deeper you go, because the database counts past everything it skips.
Cursor pagination hands the client an opaque token meaning "continue from this exact position", which stays correct while the data changes underneath and stays fast at any depth. The cost is that you cannot jump to page seven — and for an API consumed by programs rather than by a page of numbered links, nobody wanted to.
Versioning, and how to need less of it
Versioning is usually discussed as a choice of mechanism — a path segment like /v2, a header, a media type. That argument is worth about ten minutes: put the version in the path, because it is visible in logs, in a browser and in a support ticket, and move on. The decision that actually matters is how often you need a new version at all.
Most changes do not need one. Adding a field, adding an endpoint, adding an optional parameter, accepting a value you previously rejected — all of these are additive, and a client that ignores what it does not recognise keeps working. Publishing that expectation is half the work: state that clients must ignore unknown fields, and adding fields stops being a breaking change by agreement rather than by luck.
| Change | Breaking? | Why |
|---|---|---|
| Add an optional response field | No | Tolerant clients ignore it |
| Add a required request field | Yes | Every existing call now fails |
| Rename a field | Yes | A removal and an addition, together |
| Tighten a validation rule | Yes | Requests that worked now do not |
| Add a new enum value | Usually | Clients switch exhaustively on it |
| Change the default of an optional field | Yes | Behaviour changes with no call change |
The fifth row catches people out and is worth internalising: adding a value to an enum is a breaking change in most languages, because a client that handled three cases exhaustively now meets a fourth. If you expect a set to grow, say so in the documentation from day one and tell clients what to do with an unknown value — otherwise the first addition is an outage in somebody else's system.
When a version is genuinely needed, the cost is not creating it — it is running two. Every fix has to be applied twice, every support conversation starts by establishing which version, and the old one never empties on its own. Publish the removal date with the new version, and expect to spend the intervening year telling people about it.
REST, GraphQL, gRPC
The three styles are not ranked. They make different trade-offs about who decides the shape of a response, and the right answer depends on who is calling.
| Style | Fits | Strength | Cost |
|---|---|---|---|
| REST over HTTP | Public and partner APIs | Cacheable, debuggable, universally understood | Over- and under-fetching |
| GraphQL | Many clients with different needs | The client asks for exactly what it needs | Caching, rate limiting and query cost |
| gRPC | Service-to-service, internal | Fast, typed, generated clients, streaming | Not readable in a browser or a curl |
The pattern in practice: REST at the edge where consumers are unknown and longevity matters, gRPC between services you control and deploy together, GraphQL where a handful of rich clients each need a different slice of the same graph and the alternative is twenty bespoke endpoints. Mixing them is normal — the style is a property of one boundary, not of the whole system.
How this shows up in real delivery
The practice that changes outcomes more than any individual design rule is writing the specification before the implementation. An OpenAPI document produced first can be reviewed by the people who will consume it, used to generate a mock they can build against on the same day, and used to generate client libraries and request validation. Written afterwards, from the code, it documents whatever was built — including the parts nobody would have agreed to.
The second practice is contract testing. A test suite that runs the specification against the running service catches the drift that documentation always develops, and consumer-driven tests — where each consumer publishes what it actually depends on — turn "is this change breaking?" from a judgement call into a build result. That matters most for the changes that look harmless, which is precisely where the expensive mistakes are.
Where it degrades
200 OKwith an error in the body, which hides the failure from every layer that reads status codes.- Database columns exposed directly as response fields, which makes every schema change a breaking API change.
- Error messages as the only machine-readable signal, so clients match on English strings.
- Offset pagination on data that changes, which silently skips and duplicates rows.
- A new version for a change that could have been additive, which doubles the maintenance for nothing.
- A specification generated from the code after the fact, which documents accidents as decisions.
- No stated deprecation window, so the old version is still carrying traffic three years later.
When to use it
Use it when
- Consumers are outside your deployment — another team, a partner, the public.
- The interface will outlive the implementation behind it, which is the normal case.
- You can write the specification first and get it reviewed before anything is built.
- Contract tests can run in CI, so "is this breaking?" is answered by the build.
Avoid it when
- Two modules inside one deployable — a function signature is the contract, and it is checked by the compiler.
- A single consumer you deploy together with — you can change both at once, so ceremony buys nothing.
- The shape is still changing weekly — stabilise the domain before publishing a promise about it.
- Versioning a public interface as a substitute for deciding what belongs in the contract.
Found this useful?
Share it with someone who is working on the same problem.