DevelopmentAll levels

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.

The six decisions above the line are the contract: consumers depend on them, so changing one is a breaking change that other people have to act on. Everything below the line is implementation, which you can rewrite whenever you like. Getting a decision on the wrong side of that line is what makes an API expensive to live with.

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.

ChangeBreaking?Why
Add an optional response fieldNoTolerant clients ignore it
Add a required request fieldYesEvery existing call now fails
Rename a fieldYesA removal and an addition, together
Tighten a validation ruleYesRequests that worked now do not
Add a new enum valueUsuallyClients switch exhaustively on it
Change the default of an optional fieldYesBehaviour 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.

StyleFitsStrengthCost
REST over HTTPPublic and partner APIsCacheable, debuggable, universally understoodOver- and under-fetching
GraphQLMany clients with different needsThe client asks for exactly what it needsCaching, rate limiting and query cost
gRPCService-to-service, internalFast, typed, generated clients, streamingNot 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 OK with 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.