Lesson 028 · Phase 1, Foundations

API Design: Contracts That Survive Change

What an interface actually promises, why adding is safe and changing is not, and why your oldest caller is usually your own cached page.

20 min read

Lesson 28 · 28 published · 90 planned

On this page
The systems in this lessonUsed here: Marlow Books, Stagefront, and Galewatch.

Made up for this course and reused from lesson to lesson so their numbers become familiar. None of them exist. All three

Marlow Books · A small online bookshop
Four people, one server and one Postgres database. About 40 requests a second on a normal day and ten times that in the week before Christmas. The one box that the early lessons stress until it breaks.
Stagefront · An event ticketing service
Quiet most of the time, then a stadium show goes on sale at 10:00 and two hundred thousand people press the same button in the same minute. Oversold seats are a lawsuit, so correctness matters as much as speed.
Galewatch · Telemetry for wind farms
Nine hundred turbines, a reading every two seconds, over links that drop for hours in bad weather and come back with a backlog. Dashboards that lag by seconds, reports that scan a year.

Marlow Books is an online bookshop run by four people, and it exists only in this course. In September it spent twenty eight minutes hand editing a template to turn its own checkout off, which is the story lesson 027 told, and the week after that it built the switch it should have had: two fields added to a small JSON response the book page already fetches on every load. Whether checkout is open. A sentence to show when it is not.

On a Tuesday in October the founder improved it.

September had cost more than it needed to, because the shop had used one switch to say two things: we cannot take your money, and we can take it but the courier is not collecting until Thursday. So checkout_open, which held true or false, became checkout, which holds "open", "closed" or "delayed". Twelve lines in the handler, one in each box's file, one in the template. A deploy at twenty past eleven, which takes the site down for about ninety seconds because lesson 003 established that every Marlow deploy does.

Nothing broke. The origin served the new field, the template read it, the button worked. The founder loaded a book page, saw a working shop, and went to lunch.

For the next eight minutes, a healthy bookshop told a good share of its customers that it could not take orders.

Lesson 022 put the book page HTML behind a content delivery network in March, cached ten minutes at nine edges, each copy laid down when its own city first asked for it. So from twenty two minutes past eleven the origin ran new code while nine caches held pages written by the old code. That old page asks for checkout_open. It is not there any more. So it does exactly what the founder wrote in September: if checkout is not open, grey out the buy button and print the sentence. The sentence arrives under its own name in the same response, notice, and in a healthy shop it is empty.

A dead buy button with no explanation beside it. The precise thing lesson 027 said never to ship, out of a switch that had obeyed 027's rule about caches: fetched on every load, so it beat them. The page reading it did not.

It cured itself. No edge could have taken a copy of the old page later than twenty past, so the last of the nine let go by half past, one city at a time. That is the decay lesson 023 published when a promotion ended at the origin and the edges expired on their own clocks.

Eight minutes is about ten orders of buying time, on lesson 027's figure of one order every fifty seconds at an ordinary forty requests a second, which assumes the Christmas conversion rate holds on a Tuesday in October and nobody has checked. If the nine copies were laid down evenly across the ten minutes before the deploy, and nobody measured that either, the average edge had three of its ten minutes left when the new code came up. That is forty percent of the window, not the half lesson 023 measured on the same shape, because the deploy's own ninety seconds ate the first slice. Call it four orders.

One customer emailed at twenty six minutes past eleven to say she liked being told when the shop could not take orders but did rather want the book. The founder read it at twenty to twelve, clicked the link, bought the book to check, and apologised for a blip they could not reproduce.

The following Tuesday they renamed a different field in the same response. The second email arrived at twenty five minutes past eleven.

Two customers, a week apart, one minute apart on the clock, each a few minutes after a Tuesday morning deploy. That is when it stopped being a blip.

Your contract is what your callers depend on

An API, an application programming interface, is the surface one program offers another. Its contract is everything a caller may rely on and get away with, which is broader than the document, and the gap between the two is where this lesson lives.

Four promises hide in there with four different lifetimes.

The shape is the field names, their types, what nests inside what, which verbs and paths exist. The meaning is what the number actually is: stock: 3 counts copies on a shelf in a room, not copies minus the ones sitting in unconfirmed baskets. The errors are what a caller gets when you cannot do the thing, status code included, and lesson 004 put that plainly: everything between you and your caller reads the number and never opens the body. And the guarantees: whether it is safe to send twice, and how fresh the answer is promised to be.

Shape breaks loudly. Meaning breaks silently, and is worse, because nothing throws an exception when a field starts counting something else.

Lesson 016 has a line that wants hearing one radius further out: the schema is the only code every writer runs. At a service boundary, your validation is the only code every caller runs, and an optional field with no rule on it has its meaning decided by strangers.

Lesson 012 made a contract decision here and called it something else. Of its four ways to stop a customer's own review vanishing, the one Marlow shipped was to stop reading: the write already has the answer, so the handler rendered the page rather than redirecting to a read that might land on a replica six milliseconds behind. The API version, which 012 offers in the same breath, is to return the created row. What your write returns is a consistency mechanism. A body on a freshly created thing is the cheapest causal guarantee anybody ships, and it is only on offer while you are choosing the shape.

Now the part nobody plans for.

Galewatch, which collects a reading every two seconds from each of nine hundred wind turbines, had a slow dashboard early on. Lesson 002 took it apart: the page fetched the farm's turbine list and then each turbine's latest reading in a loop, sixty one calls in series over a link with a forty five millisecond round trip, three seconds of a nine second page spent waiting for light. The prescription was an afternoon of work. Make sixty calls into one. Lesson 010 later named the shape, N plus one.

The batch endpoint went in one spring afternoon and the dashboard moved to it. Traffic on the per turbine path collapsed to a line nobody could see, which everybody read as finished, and the following August it was deleted in a cleanup.

At nine the next morning an engineer at a customer that owns two of the farms rang to ask why Galewatch had broken their integration.

They had found the sixty call path in a browser's network tab years earlier and written a spreadsheet against it that ran once an hour through the working day. Nine runs, sixty one calls each, five hundred and forty nine requests a day, on a graph whose ingest line carries 38.88 million. Nobody at Galewatch had published that endpoint, documented it, or been asked about it.

It was back by lunchtime. Lesson 002's afternoon of work had been a breaking change to an API nobody knew they had shipped, and the graph that authorised the deletion was correct in every particular. Traffic tells you how much and it never tells you who. The last one percent of a line going to zero can be the caller who matters most, and no chart of requests a second will show you that.

Adding is safe, changing is not

The working rule is short and the exceptions under it are the lesson.

Change to a response Safe? What finds out
Add a field usually a caller that rejects unknowns
Rename a field no every caller, at once
"14.00" becomes 14 no, and quietly prices with awkward pennies
Add a value to an enum no a branch you cannot see

In words, since the spoken version skips tables. Adding a field is usually fine. Renaming one breaks everybody at the same moment, which at least you find out about. Turning a string into a number breaks some callers on some values. And adding one more allowed value to a field that already had a fixed set of them looks like an addition and behaves like a rename.

Adding is safe only if your callers ignore what they do not recognise, and that is a property of their code. A hand written parser walking the keys it expects will not care. A schema validator set to reject unknown properties fails the whole response over a field it had no use for. You cannot audit that, so the honest rule is that adding a field is safe in proportion to how much you know about who is reading it.

The type change gets shipped by people being tidy. Marlow's price comes out of a Postgres numeric column and the encoder renders it as the string "14.00", which looks wrong to anybody who has just learned JSON has numbers in it. Make it a number and the trailing zeros go, since 14.00 and 14 are the same JSON number, so a page printing the value into the markup now offers a paperback at $14. That is the lucky half, because you can see it.

The unlucky half is that the JSON specification declines to say how precise a number is. It notes that you get good interoperability by assuming no more range or precision than a double, and a double cannot hold most decimal fractions exactly. The promotional price lesson 023 gave that same title, $11.20, survives being multiplied by a hundred and truncated to pennies. A book at $8.20 does not: eight point two times a hundred is 819.9999999999999, so a client that truncates rather than rounds charges 819 pennies for an 820 penny book. One penny, on some prices and not others, forever, which is why it walks through every test anybody wrote. Money in JSON is a string or an integer count of the smallest unit, and which you pick matters far less than leaving the type that is wrong on Tuesdays.

Which brings back the enum, and Marlow's October. checkout_open was a boolean, and a boolean is a field that has already decided there are exactly two cases. The third case then arrives as a type change.

The move the founder should have used has a name the reader will meet in a design review inside a year, expand and contract, sometimes parallel change. Widen a set of values by teaching the readers first. Ship a reader that understands both shapes, wait until the last reader that does not is gone, then send the new thing. Contract afterwards, once nobody speaks the old shape. Marlow's version is two lines: read checkout if it is there and checkout_open if it is not, deploy, wait out lesson 022's ten minute expiry with a margin, then change the handler. Two deploys for one change. Lesson 052 does this to stored data.

One rule would have deleted the whole outage. The old page saw neither field and treated the absence as false. Lesson 027's test was sitting right there: fail open on a suggestion, fail closed on a decision. The banner is a suggestion. The decision is lesson 015's conditional update inside the checkout, which was healthy throughout and would have refused any order it could not honour. A missing field is a value, so the contract has to say which one. Write it beside the field, on both sides, because your caller's default is part of your behaviour whether you chose it or not.

The errors are the contract

Nobody documents the errors. Everybody depends on them.

A status code is a branch in somebody else's program, so publishing a new one ships a code path into software you cannot read. Lesson 004 established that a 4xx fails the same way if you resend it while a 5xx might succeed, that 503 means out of room right now rather than broken, and that 429 is the one 4xx that will succeed later. Lesson 019 sharpened that: a 503 tells you nothing happened, while a timeout tells you nothing at all. Lesson 020 built the client that acts on the difference.

So a well meant change from 500 to 503, on the grounds that 503 is the truthful code for a busy service, hands every caller running that client a retry they were not doing yesterday. More honest and more load, at once.

Hence the discipline. The same failure always carries the same code, and a code means what the specification says rather than what feels descriptive. A 200 carrying a body that says the operation failed is the worst available answer, because every proxy, balancer and retry client in the path has already filed it as a success.

Lesson 021 handed the vocabulary here: a 429 with a Retry-After is a rescheduling, and a limiter is the one component that knows the right number exactly, because it has a bucket. Three things belong in that response and only one is standardised.

HTTP/1.1 429 Too Many Requests
Retry-After: 6
RateLimit-Remaining: 0

{ "error": "rate_limited",
  "limit": "search_per_address",
  "message": "Too many searches from this address. Try again in six seconds." }

Retry-After is in the HTTP specification and every client library knows it. The remaining quota is not, so it reaches you as X-RateLimit-Remaining or RateLimit-Remaining or X-Rate-Limit-Remaining depending on whose service you called last, and an IETF draft has been trying to settle that for years. A convention everybody invented separately is one you have to document, because your caller cannot guess it and will not read your blog.

Note what the body carries: a stable token for the branch, rate_limited, and a sentence for the human, in different fields. A caller will parse your error message if you do not give it something better to parse, and then your copy edit is their outage. I have shipped that outage. Somebody changed "Insufficient stock" to "Out of stock" and a partner's integration stopped reordering.

Lesson 027 said a degraded mode has to name the thing it cannot do, in the words of the thing the customer came for. That is a contract obligation and not a copywriting one, because the mode has to be expressible: if the only way your API can say "not taking orders today" is a 500, the honest banner cannot be built above it.

Then the gap, which lesson 004 already named in its recap. There is no status code for "I do not know whether it worked". A payment call that times out after ten seconds, lesson 019's exact case, has no number in the specification for what actually happened; the nearest is 409 Conflict, for a request whose twin is in flight. That absence is why idempotency keys exist. When a protocol cannot express a state, somebody carries it in the payload, and that somebody is you.

Lists break first

Every API starts with one clean idea per URL and stays clean until its first list.

Galewatch's readings are the hard case, because 450 arrive every second across the fleet and thirty a second at the Tarrow Ridge farm alone, whose sixty turbines lesson 010 priced as a 7.3 gigabyte fortnight, which at the two hundred bytes a reading lesson 001 measured is thirty six point three million rows. Offer the obvious endpoint, a page of readings with a limit and an offset, and the second page is a lie. Ask for newest first and every row arriving while the client reads pushes the window down, so offset one thousand now points at rows it has already seen. Duplicates on a table that grows at the front, gaps on one that shrinks. Nobody notices for months, because they look like the duplicates lesson 019 found in this same system for an entirely different reason.

The fix is the index they already own. Order by something unique and monotonic, then ask for what comes after the last thing you saw. Lesson 010 made (turbine_id, recorded_at) the course's example of correct composite column order, and lesson 019 argued for making it unique so a resent reading collapses into the row it duplicates. A cursor is the index you already have, written down in the response. Hand it back opaque, and mean opaque: the moment a caller can read offset=2000 in your URL, that arithmetic is in their code.

Cursors do not save you from size. Thirty rows a second makes a page of a thousand rows thirty three seconds of one farm's life, so a fortnight is thirty six thousand pages, ten hours at one request a second. A year of that one farm is nine hundred and forty six million rows, and lesson 002 already told you how Galewatch serves an answer too big for the link, because it has: three years of history on physical drives, five hundred kilometres overnight in a van at about 230 megabytes a second, against thirty nine days over the site link.

There is a size above which an API is the wrong shape for an answer, and the move is to change the verb. Ask for an export, get a job identifier, poll it, download one file from object storage. Lesson 018 called this the half of later a caller hands you, lesson 017's queue carries it, and lesson 016 has somewhere to put the file. A list endpoint is a promise about a size, and when the size is wrong the answer is a job, not a page.

One trap from the other direction. Batching sixty calls into one creates a question the single call never had: what does the response say when fifty nine turbines answer and one does not? Lesson 027's rule holds here. A fan out decides in advance and in code whether a missing leg is an error or a footnote. Fail the whole batch and one bad turbine deletes the dashboard; return fifty nine and say so, and the caller has to be built to notice. HTTP does have a code for partial success, 207 Multi-Status, which comes from WebDAV and which almost nobody has ever received. In practice you return 200 with a status beside each item and write that down prominently, because a caller who assumes a batch is all or nothing has a silent data loss bug that will be blamed on you.

The oldest client you cannot upgrade

The number that decides how long you have to keep an old shape alive is never the one in the deprecation notice.

Caller How long the old shape must keep working
Marlow's own cached book page ten minutes, lesson 022's expiry
A tab somebody left open as long as they leave it open
Stagefront's phone app months, and some of it never
A turbine's firmware until an engineer drives to the turbine

Read that in prose, because it is the spine of the section. A shop with no partners, no public API and no mobile app still has a client it cannot upgrade, and it is its own page, sitting in nine caches and in every browser that loaded it before the deploy. Your oldest client is your own cached page.

Stagefront, the ticketing service in this course where a stadium show goes on sale at exactly 10:00 and two hundred thousand people press the same button in the same minute, has the version that costs money. Lesson 020 established the configuration: a phone app making three attempts at a six second timeout. That app is in a store. You cannot deploy it, some people update in a week and some never, and lesson 021 worked out what those three attempts do to a limiter, six thousand refusals a second against five hundred admissions. Your caller's retry policy is part of your contract and you did not write it. If you want retries to behave differently during an on sale, the only lever is in your own response, which is why Retry-After is worth sending even to clients you suspect ignore it.

Galewatch has the far end of the scale. Lesson 021 asked the reader to consider three hundred turbines belonging to a customer running their own firmware, which Galewatch cannot change, and left the question open. As a contract question the answer is stark: that ingestion shape is frozen for the life of the hardware, and every version negotiation happens on Galewatch's side of the wire. An ingestion endpoint is the most expensive contract in any product, because its callers are bolted to towers.

Which puts one thing before versioning that people do after it. You have to be able to attribute the calls. Galewatch deleted an endpoint because a graph said nobody was calling it, when all the graph could say was that the volume was small. Lesson 021 reached the neighbouring rule from the limiting side, that the requests you most want to limit are the ones you cannot attribute. You cannot bill, throttle or retire what you cannot name, and a graph of distinct callers per endpoint rather than requests per endpoint is what turns a deprecation from an announcement into a conversation.

Then the limit, which is not a technique.

You will not learn your contract by reading your own documentation, because it is the set of assumptions your callers have made, most of which you never sanctioned and one of which is a spreadsheet you have never seen. A contract is only as strong as the oldest client you cannot upgrade, so every interface you ship is a promise whose end date somebody else chooses. Keep the promise small. Every field you publish is a decision you can no longer take back on your own.

Recap

Your contract is what your callers depend on, not what you wrote down. Four parts, four lifetimes: the shape, the meaning, the errors and the guarantees. Shape breaks loudly and meaning breaks silently, which is the wrong way round. What a write returns belongs to it too, since lesson 012's cheapest fix for a vanishing review was to notice the write already knew the answer and hand it straight back, which makes a response body a consistency mechanism rather than a nicety.

Traffic tells you how much and it never tells you who. Galewatch deleted an endpoint that looked like a flat line at zero and broke a customer's spreadsheet running five hundred and forty nine requests a day. Count distinct callers, not requests, or you cannot deprecate anything honestly.

Adding is safe only if your callers tolerate what they do not recognise, and that is their code, not yours. A rename breaks everybody at once, which is the merciful version. A string turning into a number breaks some callers on some values: $11.20 survives the trip through a double and $8.20 comes out a penny short. And a boolean is a field that has already decided there are exactly two cases, so the third case arrives as a breaking change. Widen a set of values by teaching the readers first and waiting out the oldest one.

A missing field is a value, so the contract has to say which one. Marlow's old page read an absent field as "checkout closed" and shut a healthy shop for eight minutes, when lesson 027's test was sitting there: fail open on a suggestion, fail closed on a decision, and the banner was never the decision.

The errors are the contract, and a status code is a branch in somebody else's program. Same failure, same code, every time, and a 200 with a failure in the body defeats every machine in the path. Give the branch a stable token and the human a sentence, in different fields. There is still no code for "I do not know whether it worked", which is the hole idempotency keys fill.

A list endpoint is a promise about a size, and when the size is wrong the answer is a job, not a page. Offsets duplicate rows on a table that grows at the front, and a cursor is the index you already have, written into the response.

Your oldest client is your own cached page, and a contract is only as strong as the oldest client you cannot upgrade. Ten minutes for an edge, months for an app store, the life of the tower for firmware bolted to it. Publish less, so there is less a stranger can come to depend on.

Check your understanding

  1. Marlow wants to rename the notice field in that live JSON response to message. Write the deploy plan in order, with the wait between steps and the number that sets it, and say what the plan would be if the page were not cached at all.

  2. Galewatch offers GET /readings?farm=tarrow-ridge&limit=1000&offset=N, newest first. Using Tarrow Ridge's published thirty readings a second, say what a client reading page one and then page two ten seconds later actually gets, then write the cursor version and say which field it is built from and why that field already exists.

  3. Stagefront wants to start returning 429 with a Retry-After during an on sale. Its phone app makes three attempts at a six second timeout and cannot be updated. Say what that changes for the app already in the field, what it changes for the next version, and whether it is worth shipping anyway.

  4. Marlow's book page shows a stock badge from a field that has always been an integer. Somebody proposes sending "few" when stock is below three, to avoid revealing inventory. Name three callers that could break, in order of how long each takes to fix, and give a shape for the change that breaks none of them.

  5. Take an endpoint you own. List what a caller could depend on that you have never written down: a field order, a default, an error message, a timeout. Then say how you would find out who is calling it today, and how long that would take.

Next lesson

029 Authentication and Authorization at the Edge. Today ended on attribution, because you cannot retire, throttle or bill a caller you cannot name, and next lesson is about where that name comes from, what it is allowed to prove, and why the place you check it is usually the wrong one.

Finished reading?

Marking a lesson done keeps your place on the course index. It is stored only in this browser.

Tip: use the ← and → keys to move between lessons.