AmouAI Hub/Courses/Software Engineering for AI Engineers/Chapter 3
API Design as a Contract You Cannot Take Back
Everything else in a codebase can be refactored on a quiet afternoon. An interface cannot, because sixty other people’s code is now shaped around it and none of them read your changelog. This is the one place where a decision made in ten seconds is paid for over five years.
By the end of this chapter you can
- Choose between REST, RPC and GraphQL on evidence rather than fashion
- Put a version in the contract on day one, and say what it costs to add later
- Design pagination that neither skips nor repeats a row under concurrent writes
- Make any write safely retryable, and explain why a network timeout demands it
- Return errors a client can branch on without parsing prose
- Classify a proposed change as breaking or non-breaking, and defend the classification
1A promise you cannot withdraw
The difference between an internal function and a published endpoint is not technical. It is that you can see every caller of one and none of the callers of the other.
Rename an internal function and the compiler, or a grep, finds every caller in an afternoon. Rename a JSON field in a public response and you find the callers when they file support tickets, in an order determined by how often they deploy. Some of them are on a release cycle measured in quarters. One of them has been acquired and nobody there knows the integration exists.
So the useful mental model is not “an API is code”. It is: an API is a promise, and every field in it is a clause. Adding a clause is cheap. Removing one is a negotiation with people who never agreed to negotiate.
| Change | Breaking? | Why |
|---|---|---|
| Adding an optional response field | No | Clients that ignore unknown fields are unaffected — and a client that does not ignore them is already broken. |
| Adding an optional request parameter | No | Existing calls keep their old behaviour, provided the default preserves it. |
| Adding a required request parameter | Yes | Every existing call now fails. This is the one people misfile most often. |
| Renaming a field | Yes | Two changes at once: removing one clause and adding another. |
| Narrowing a type (string → enum) | Yes | Values that used to be accepted now are not. |
| Widening a type (enum → string) | Yes, in responses | Safe in a request, breaking in a response: clients that switch on the enum now hit an unhandled case. |
| Making a nullable response field non-nullable | No | Clients already handle null; they will simply never see it. Breaking in a request, though. |
| Changing a default value | Yes | Silently. Nothing errors, and behaviour changes for every caller who relied on the default — which is the worst kind. |
The asymmetry between requests and responses runs through all of this. Requests can safely become more permissive; responses can safely become more informative. Anything in the other direction breaks somebody. Postel’s advice to be liberal in what you accept and conservative in what you send is not a style preference — it is the direction in which change is free.
2REST, RPC and GraphQL, honestly
All three work. They optimise for different things, and each has a failure mode its advocates do not lead with.
REST organises the interface around resources and uses HTTP’s own vocabulary for the verbs.
Its real benefit is that the whole internet already understands it: caches, proxies, load balancers and
browsers all know what GET means. Its failure mode is that a lot of real operations are not
nouns. “Reconcile this invoice against these three payments” has no resource, and the honest
REST answer is to invent one, which is where the design starts to feel like a costume.
RPC organises around operations, which matches how most systems actually think. It is the natural
choice for internal service-to-service calls, and gRPC’s generated clients and schema evolution rules
are genuinely good. Its failure mode is that HTTP semantics disappear: everything is a POST,
so nothing intermediate can cache anything, and idempotency becomes purely your problem.
GraphQL lets the client ask for exactly the fields it needs, which kills over-fetching and the
endpoint-per-screen sprawl. Its failure mode is that you have handed clients an unbounded query language
and inherited every problem that comes with one: query cost analysis, depth limiting, per-field
authorisation, and caching that no longer works at the HTTP layer because everything is a POST
to /graphql.
Choosing GraphQL for a public API with a small team. The pitch is that clients stop asking you for new endpoints; the reality is that you now own query cost limits, depth limits, persisted queries, and field-level authorisation on every field you expose. The symptom is a single deeply-nested query from one integrator taking the database down, and the fix being an entire subsystem nobody scoped. GraphQL earns its keep when many clients need many shapes and you have the capacity to operate it.
Compose an endpoint below and read the contract that falls out, including the parts you did not deliberately choose.
3Versioning, on day one or not at all
“We will add versioning when we need it” is the plan that guarantees you will need it on a day when adding it is impossible.
The reason is simple. A version is only useful if clients are already sending it. Introducing
/v1/ after clients exist means the un-versioned URLs must keep working forever, so you now
maintain two surfaces and have gained nothing for the callers who most need protection — the ones who
integrated first and update least.
Three mechanisms, in rough order of how often they are the right answer:
- Version in the path (
/v1/invoices). Ugly, obvious, trivially routable, and impossible to get wrong. For a public API this is usually correct precisely because it is visible in every log line and every support ticket. - Version in a header (
Accept: application/vnd.acme.v2+json). Purer, and it keeps one URL per resource. It is also invisible in a browser address bar, easy to omit, and requires you to decide what an absent header means — which is a decision you will get wrong once. - No versioning, additive only. Legitimate, and stricter than it sounds: it means you may never remove or rename anything, ever. Some very large APIs do this successfully and pay for it with a schema full of deprecated fields nobody may delete.
A version lets you publish a breaking change. It does not let you stop running the old one. Every version you ship is a surface you operate, test and secure until the last client migrates, and the last client never migrates voluntarily. Decide the deprecation policy — how long a version lives, how clients are warned, what happens when the date arrives — at the same time as you decide the mechanism. A version with no sunset policy is not versioning; it is accumulation.
4Pagination that does not lie
Offset pagination is the default in every tutorial and quietly incorrect in every system where rows are inserted while a client is reading.
The problem is that OFFSET 20 means “skip the first twenty rows of the result set as it
exists right now”. If two rows are inserted near the front between page one and page two, the rows
that were at positions 19 and 20 have moved to 21 and 22 — and page two starts at 21, so the client
never sees them. Nothing errors. The export is simply missing two invoices, and nobody finds out until an
accountant does.
The mechanism is one line of SQL. Instead of counting rows to skip, remember the last row you saw and ask for what comes after it:
-- unstable: skips and repeats under concurrent writes
SELECT id, status, total_cents, placed_at
FROM invoices
WHERE org_id = $1
ORDER BY placed_at DESC
LIMIT 20 OFFSET 40;
-- stable: continues from a position, not a count
SELECT id, status, total_cents, placed_at
FROM invoices
WHERE org_id = $1
AND (placed_at, id) < ($2, $3) -- the cursor
ORDER BY placed_at DESC, id DESC
LIMIT 20;
Two details make it work. The sort must be on a unique tuple — placed_at alone
is not unique, so ties would be skipped, which is why id joins the sort and the comparison.
And the cursor should be opaque to the client, so that you can change its contents later without a
breaking change. Base64 of a small JSON object is entirely sufficient; it is not encryption and does not
pretend to be.
Offset pagination is still the right answer for a page-numbered admin table where a user can click “page 7”, small drift is invisible, and a cursor cannot express “jump to seven”. Naming that as a deliberate trade is the difference between a decision and an accident.
5Making a write retryable
Every network times out. When it does, the client cannot tell whether the server never received the request or received it, committed it, and failed to reply. Those two cases require opposite responses, and the client has to pick one.
Without help, the client faces a bad choice. Retry, and it might create a second invoice. Do not retry, and it might lose the write entirely. Most client libraries retry, so “plain POST plus flaky network” is a duplicate-creation machine.
The fix is to let the client name the logical request. It generates a unique key, sends it as a header, and the server stores the outcome against that key. A repeat of the same key returns the stored response instead of doing the work again.
GET, PUT and DELETE are idempotent by definition, and
POST is not. That is not trivia — it is the reason every proxy, client library and
load balancer in the world will retry the first three and hesitate over the fourth. If a
POST creates something, it needs an idempotency key, and if it does not have one you have
decided that duplicates are acceptable whether or not you meant to.
6Errors are data, not prose
A client cannot branch on a sentence. If your error is a string, integrators will match on its text, and your next copy edit becomes a breaking change nobody labelled as one.
An error needs to answer three questions, and prose answers none of them reliably: what kind of failure is this (so the client can branch), is it worth retrying (so the client knows whether to back off or give up), and what exactly was wrong (so a human can fix it). RFC 9457 gives a conventional shape for this, and the convention matters more than the specific field names:
{
"type": "https://acme.dev/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "SKU-119 has 2 remaining, 5 were requested",
"instance": "/v1/orders/8812",
"sku": "SKU-119",
"available": 2,
"requested": 5
}
type is the stable identifier a client switches on — it never changes, even when the
wording does. title and detail are for humans and are free to be edited.
The extra fields carry the machine-readable specifics, so a client can offer “order the 2 that are
available” without parsing a sentence to find the number.
Status codes carry the coarse signal, and getting the class right matters more than the exact code. 4xx means the client should change something before retrying; 5xx means the client may retry unchanged. Returning 200 with an error object in the body breaks every piece of infrastructure that reads status codes — retry policies, circuit breakers, alerting, load-balancer health — and it is one of the most consequential five-second decisions in this chapter.
Take any endpoint you have shipped and write down its contract: the version mechanism, the pagination model, whether a retried write duplicates, and the exact shape of its errors. Then find one field you would rename if you could, and work out honestly what it would take. That number is what the ten seconds you spent naming it actually cost.
✓Checkpoint
Five questions. Commit before you read the explanation.
▶Playground
Ledger is opening its public API. Configure it against the stated constraints, then read the defensible answer — and notice which of your choices was style and which was load-bearing.
✓Exercise set
Twelve problems. The JavaScript ones build the actual mechanisms this chapter argues for — cursors, idempotency replay, contract diffing — and the design ones ask you to make the same decision twice under different constraints and get a different answer. Your work is saved in this browser.
Chapter 4 — State, Sessions and Who You Say You Are
State is the thing that makes a server hard to replace, hard to scale and hard to reason about. Deciding where it lives is the highest-leverage decision most people make by accident.