API design for long term stability: versioning strategies, additive change, cursor pagination, tolerant readers, deprecation headers, and REST vs gRPC vs GraphQL trade offs.
A few years ago I shipped an API I was quietly proud of, and six months later I couldn't change a single line of it without breaking a stranger. A mobile client I'd never spoken to was depending on a field I'd added "just in case." A partner integration was parsing my error strings with a regex. The routes were clean, the JSON was tidy, everything was named exactly right, and none of that mattered. The API was fine. My freedom to fix it was gone.
That's the part nobody warns you about. An API is not code you wrote; it's a promise you made to people you'll never meet, who will use it in ways you never imagined, and who will not read your changelog. Once a client depends on your endpoint, you can't recall it. Shipping an API is easy. Living with one for three years is the actual job, and almost every decision that matters for those three years gets made in the first three weeks, before you have a single external user to tell you you're wrong.
This is a post about designing for backward compatibility on purpose: how to pick a versioning strategy, how to evolve a schema without breaking old clients, which pagination and error shapes survive scale, and how to deprecate an endpoint without burning the people who trusted it. None of it is exotic. All of it is the stuff I wish someone had drilled into me before I published my first breaking change wearing a bugfix costume.
The mental model that fixed my API design wasn't technical. It was legal. An API is a contract. You publish it, someone builds on it, and now you owe them stability whether or not you signed anything.
The trap is that the contract is bigger than you think. You believe the contract is "the fields I documented." Your consumers believe the contract is "everything the endpoint actually did the day I integrated." Those are wildly different scopes, and the gap between them is where three years of pain lives.
I've seen all of these treated as load-bearing by someone downstream:
None of that was in my spec. All of it became my problem. So the first discipline of an API that ages well is honesty about surface area: everything observable is part of the contract, documented or not. The way you shrink future pain is by shrinking what you expose today, not by writing a document that says "please only rely on these bits." Nobody reads that document. They read the response, and they build against whatever it happens to do.
The practical move is to expose the minimum that satisfies the use case, and hold the rest back. It is trivial to add a field later. It is nearly impossible to remove one. Design from that asymmetry and half your future problems never get born.
Everyone asks about versioning first because it feels like the safety net. If I can just ship v2, I can fix all my mistakes. That instinct is exactly backwards, and I'll get to why. But first, the menu of API versioning strategies.
URL path versioning — /v1/orders. Ugly, obvious, and honestly fine. It's greppable, it's cacheable, it shows up in logs, it's trivial to route at the gateway, and any developer understands it in half a second. This is what I reach for by default, and it's what most large public APIs land on for exactly these reasons.
Header / media-type versioning — Accept: application/vnd.myapp.v2+json. Purer in theory: the resource URL stays stable across versions, which is the REST-orthodox position. In practice it's invisible. It breaks casual browser testing, it confuses CDNs and caches that key on URL, and it hides the one piece of information a debugger most wants to see. I've watched a team lose an afternoon to a caching layer that ignored the version header entirely and happily served v1 bytes to a v2 request.
Query-param versioning — /orders?version=2. Please don't. It tangles versioning up with the thing query params are for, it's trivially easy to drop, and it muddies caching. It looks convenient on day one and becomes a wart you can never remove.
Here's the opinion the whole section is building toward: the strategy that ages worst is treating a version bump as your primary tool for change. A big-bang v2 sounds clean and is a slow-motion disaster. You now maintain two full implementations. Clients have zero incentive to migrate, so v1 never dies — I've kept "temporary" v1 endpoints alive for over two years. And every bug fix and security patch has to be applied twice, or you quietly let v1 rot while real traffic still flows through it.
A new major version is the most expensive move in your toolbox. It should be the thing you do once every few years when you genuinely got the model wrong — not your routine mechanism for adding a field. The APIs that still make sense in three years are the ones that almost never bumped their major version, because they rarely needed to. Reserve the version number for a real paradigm shift and evolve everything else additively.
If you're not going to version your way out of trouble, you need another way to evolve. That way is additive change, and it has to be a rule you enforce, not a habit you hope for.
The rule is small and strict:
That's it. Live inside that box and existing clients keep working forever, because everything they read is still there and everything they send is still valid. This is the whole game of backward-compatible API evolution, compressed into one paragraph.
The failures are always the "harmless" ones. Renaming user_name to username for consistency — breaks everyone parsing the old key. Changing amount from a number 9.99 to a string "9.99" because someone got nervous about float precision — breaks every client doing math on it. Tightening validation to reject a field you used to ignore — breaks the client that's been sending garbage in that field for a year and relying on you to ignore it. That last one stings because tightening validation feels like fixing a bug. To a consumer, your bug was load-bearing.
I learned that one the expensive way. We added a NOT NULL plus a length check to a phone field that had always been optional and unvalidated. Reasonable cleanup, shipped on a Tuesday. By Wednesday morning support had a stack of tickets: an older build of our own Flutter app had been sending an empty string for users who skipped the field, and our new validation was rejecting the whole request. We'd broken checkout for maybe a few hundred users on an app version we couldn't force-update, and the fix was a same-day rollback and an apology. The validation was correct. It was also a breaking change wearing a bugfix costume, and nobody in review flagged it because we weren't trained to see tightening as breaking. Now we are.
Here's a quick reference I keep in my head for what's safe:
| Change | Safe? | Why |
|--------|-------|-----|
| Add an optional response field | Yes | Tolerant readers ignore it |
| Add an optional request param | Yes | Old clients simply omit it |
| Add a new endpoint | Yes | Nothing old points at it |
| Remove or rename a field | No | Old parsers break instantly |
| Change a field's type | No | Deserialization or math breaks |
| Make an optional field required | No | Old requests start failing |
| Tighten input validation | No | Existing "garbage in" now rejected |
A practical guard: write a contract test that snapshots your response shape and fails on any removal or type change. Diffing your OpenAPI spec in CI works even better, because it catches the change before it merges. The point is to make a breaking change loud enough that it can't happen by accident during a well-meaning refactor at 6pm.
# A CI check that fails the build on a breaking OpenAPI diff.# Additive changes pass; removals and type changes stop the pipeline.- name: Detect breaking API changes run: | npx oasdiff breaking \ openapi.baseline.yaml \ openapi.current.yaml \ --fail-on ERR
Make that check required to merge and you've converted "please remember not to break the contract" — which fails eventually, always — into a machine that remembers for you.
Some design mistakes only reveal themselves at scale, and by then they're welded into the contract. These three are the usual suspects, and all three are cheap to get right on day one and brutal to fix on day five hundred.
Offset pagination (?page=3&limit=20) is the one everyone builds first and regrets later. It's fine until the underlying list changes while a client is paging through it — then rows get skipped or shown twice — and it gets slow on large tables because the database still walks and discards all the skipped rows before returning your page. Deep pagination on a big table with OFFSET 100000 is a performance cliff waiting for your busiest customer.
Cursor pagination is the version that survives. You hand back an opaque token that encodes "where you were," and the client sends it back for the next page.
{ "data": [ /* ...items... */ ], "page": { "next_cursor": "eyJpZCI6MTQ4Mn0", "has_more": true }}The next_cursor being opaque is the whole point. Because clients can't parse it, you can change what's inside — switch from an ID to a composite sort key, add a tiebreaker for stable ordering, encode a timestamp — without breaking anyone. You made the field uninteresting on purpose, and that's exactly what keeps it flexible. An opaque cursor is a field you designed to be able to change.
Resist the urge to invent a query language in your URL. I've seen ?filter=price>100 AND status:active designs, and they are a trap: you've now committed to a parser and a grammar you have to support, escape, and secure for years. Start with plain, additive filter params — ?status=active&min_price=100. New filters are just new optional params, which is a change you're allowed to make forever. If you genuinely outgrow that, you'll know, and you can add a structured search endpoint deliberately rather than backing into a half-baked DSL by accident.
Errors are an API surface, and they're the one people design last and regret most. A bare 400 with a plain-text body means every client writes brittle string matching against your prose — and then you can never reword that prose again. Give them something stable and machine-readable instead.
{ "error": { "code": "insufficient_funds", "message": "Wallet balance is below the requested amount.", "request_id": "req_9f2c1a" }}The code is the contract — stable, documented, machine-readable, the thing clients are allowed to branch on. The message is for humans and you're free to reword it whenever you like. The request_id is the gift you give your future self at 2am when a client says "it's broken" and you need to find the exact request in your logs. Design your errors as carefully as your success responses, because clients branch on them just as hard — often harder, since error handling is where the fragile code lives.
The deepest version of designing for change is designing payloads that survive fields nobody has thought of. Two habits get you most of the way there.
Objects over primitives at the boundaries. The day you return a bare value, you've capped what that value can ever say. Compare:
{ "status": "shipped" }versus
{ "status": { "code": "shipped", "label": "Shipped", "occurred_at": "2026-06-30T10:00:00Z" }}The first is honest but final. The second gives you a home for the fields you'll want later — a timestamp, a localized label, a reason code, a carrier reference — without touching what's already there. You don't wrap everything; that's over-engineering, and a response where every scalar is a nested object is miserable to consume. You wrap the things you can feel will grow: status, money, anything user-facing, anything with a lifecycle.
Money especially: never a bare float. { "amount": "12.50", "currency": "AED" } from day one, with the amount as a string to dodge floating-point rounding. The moment you support a second currency — and building out of Dubai, you always will — the bare number becomes a liability with no room to grow, and retrofitting currency into a 9.99 that a thousand clients already treat as a plain number is a migration you'll dread.
Tolerant readers. This one is as much about your clients as your server. A tolerant reader ignores fields it doesn't recognize instead of rejecting them. If your clients parse strictly and blow up on any new key, you can never add a field — your own additive rule is dead on arrival because the readers won't tolerate it. A client that fails on unknown fields is a client that has frozen your API in place. So write your SDKs, your generated models, and your docs to make ignoring-the-unknown the default (in most JSON deserializers this is a single flag), and you keep the freedom to add for the entire life of the API. Robustness in what you accept, precision in what you send — the old Postel instinct, applied to schema evolution.
Even with perfect additive discipline, someday you'll want something gone. Deprecation isn't a failure — pretending nothing ever dies is. What separates a respected API from a resented one is honesty on a schedule.
The process that actually works looks like this:
Deprecation header and a Sunset header (both are real, RFC-backed) so the warning reaches machines and log scrapers, not just a blog post no integrator ever reads.HTTP/1.1 200 OKDeprecation: trueSunset: Mon, 01 Mar 2027 00:00:00 GMTLink: <https://docs.myapp.com/deprecations/legacy-orders>; rel="sunset"
The instrumentation is the part teams skip, and it's the part that matters most. Every "we can't remove this, we don't know who uses it" is really a monitoring failure from two years earlier. Log usage per endpoint per client from the day you launch, and deprecation becomes a data-driven decision instead of a nerve-wracking guess. GraphQL, worth noting, has genuinely good bones here: per-field @deprecated plus field-level usage analytics let you sunset one field at a time instead of one endpoint at a time — if you've done the work to actually collect that telemetry.
People argue about REST, gRPC, and GraphQL like it's a religious war. The honest question isn't which is best — it's which one you can still maintain, unbroken, in three years, given who's actually on your team.
| Style | Ages well when | Ages badly when |
|-------|---------------|-----------------|
| REST | You have clear resources and mostly external or varied clients | You force everything into CRUD and end up with POST /orders/123/actions hacks |
| RPC (incl. gRPC) | Internal service-to-service, you control both ends, contracts are code | External consumers need to poke at it with curl and read it by eye |
| GraphQL | Many clients want wildly different shapes of the same data | A small team underestimates schema governance, caching, and query-cost control |
My rule of thumb from actually shipping these:
The lens that matters is maintenance, not elegance. The best-aging API is the one your team can still reason about, safely change, and confidently deprecate long after the person who designed it has moved on. That usually means the boring choice — and picking the boring choice on purpose is one of the more senior things you can do.
codes over plain-text prose.Deprecation/Sunset headers, and instrument usage from day one so removal is a data-driven decision, not a gamble.The endpoints you can predict aren't the risk. The change you can't predict is, and good API design is almost entirely about staying free to make that change without breaking the strangers who built on you.