API Versioning & Evolution
Stage 3 · Backend & APIs · B.U.I.L.D. letter: D
Every response shape you ship is a contract — and the moment you break it, every app built on your API breaks with it.
⚠️ The vibe trap
You renamed user.name to user.fullName because it felt cleaner. You pushed to prod. Fifteen minutes later your mobile app crashes, three partner integrations go silent, and your CEO's dashboard shows "undefined" everywhere. Nothing in your code broke. You broke the people reading your code's output. This is the defining pain of working with live APIs: your users aren't just humans looking at screens — they're other developers' programs, and programs don't adapt gracefully to surprises.
🔬 What "breaking change" actually means
A breaking change is any modification that causes a previously working client to fail or produce wrong output without that client changing a single line of its own code. A non-breaking (additive) change is anything that existing clients can safely ignore.
Breaking — these will wreck clients overnight:
// Before
{ "user": { "name": "Jordan Lee", "dob": "1995-03-12" } }
// After: field renamed ← BREAKING
{ "user": { "fullName": "Jordan Lee", "birthDate": "1995-03-12" } }
// After: field removed ← BREAKING
{ "user": { "fullName": "Jordan Lee" } }
// After: type changed (string → object) ← BREAKING
{ "user": { "name": { "first": "Jordan", "last": "Lee" } } }
Non-breaking (additive) — clients safely ignore extra fields:
// After: new field added ← SAFE
{ "user": { "name": "Jordan Lee", "dob": "1995-03-12", "avatarUrl": "https://..." } }
Mental model: Think of your JSON response like a table with column names. Renaming, removing, or retyping a column breaks every spreadsheet formula downstream. Adding a new column? Nobody's formula cared about a column it wasn't using.
Common mistake: Assuming clients will "just update." In a distributed system you can never coordinate all clients to update simultaneously. You deploy at noon; a mobile app last updated in March is still running on someone's phone, hitting your API, expecting the old shape — forever.
🗂️ Versioning strategies and their trade-offs
Once you know you must introduce a breaking change, you need a versioning strategy so old clients keep working while new ones get the improved API.
Strategy 1 — URL path versioning (/v1/, /v2/)
The most common and most discoverable approach. The version is right in the URL.
GET /v1/users/42
GET /v2/users/42
// Express — registering both versions simultaneously
import { v1Router } from './routes/v1/users.js';
import { v2Router } from './routes/v2/users.js';
app.use('/v1', v1Router);
app.use('/v2', v2Router);
- Pro: Obvious, easy to test in a browser, easy to document separately.
- Pro: You can paste a
/v2URL in Slack and anyone can see it. - Con: URL should identify a resource, not a contract version — purists object.
- Con: Easy to let both versions silently diverge and rot.
Strategy 2 — Header-based versioning (Accept or custom header)
GET /users/42
Accept: application/vnd.myapi.v2+json
Or a custom header:
GET /users/42
Api-Version: 2
// Express middleware — read version from header, delegate
app.get('/users/:id', (req, res) => {
const version = req.headers['api-version'] ?? '1';
if (version === '2') return getUserV2(req, res);
return getUserV1(req, res);
});
- Pro: Clean, resource-oriented URLs.
- Con: Invisible — you can't curl it without extra flags, harder to share.
- Con: Clients frequently forget to send the header; your default behavior matters a lot.
Which to pick? URL versioning is the pragmatic choice for most teams. It's ugly in the purist sense and totally fine in the practical sense. GitHub, Stripe, and Twilio all use it.
Mental model: A version number is like a lease. V1 clients have a lease on the old shape. When you introduce V2 you're offering a new lease; you don't evict V1 tenants immediately — you give them a reasonable notice period, then sunset the old building.
Common mistake: Bumping the version for every change. Version bumps are expensive — they multiply your maintenance surface. Only bump for genuine breaking changes. Non-breaking additions never need a version bump.
🔄 The expand/contract (parallel-change) pattern
URL versioning is the strategy. Expand/contract is the safe execution pattern — how you actually roll a migration without ever breaking a client at any instant in time.
It has three phases:
Phase 1 — Expand (add the new thing alongside the old):
// Your user object now includes BOTH shapes
function serializeUser(user) {
return {
id: user.id,
name: user.name, // ← keep for old clients
fullName: user.fullName ?? user.name, // ← new field for new clients
dob: user.dob,
birthDate: user.dob, // ← new field for new clients
};
}
At this point every client — old and new — gets what they need. Old clients read name; new clients read fullName. Ship this and wait. Tell clients about fullName in your docs and Sunset header.
Phase 2 — Migrate (update all clients you control):
Update your own front end, your own mobile app, your integration tests. Confirm they all use fullName now. Give external partners time to do the same.
Phase 3 — Contract (remove the old thing):
Only after the deprecation deadline, drop name and dob from the response — or do it as a /v2 endpoint:
// v2 endpoint — clean shape, no legacy fields
function serializeUserV2(user) {
return {
id: user.id,
fullName: user.fullName,
birthDate: user.dob,
};
}
Mental model: Expand/contract is how you safely widen a road while traffic is still flowing. First you build the new lane (expand). Then you move the cars over (migrate). Only then do you tear up the old lane (contract).
Common mistake: Jumping straight to contract. Deleting name the same deploy you add fullName gives clients zero time to adapt — that's a breaking change on a schedule of "right now."
📢 Deprecation policy: communicating the sunset
Knowing when to kill old versions is as important as knowing how. A good deprecation policy has three parts: a timeline, a signal in the response, and a signal in the docs.
The Sunset HTTP header — the standard way to tell clients a version is going away:
HTTP/1.1 200 OK
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Deprecation: true
Link: <https://api.yourdomain.com/v2/users>; rel="successor-version"
Content-Type: application/json
// Express middleware — attach Sunset header to all v1 responses
function deprecationHeaders(req, res, next) {
res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.setHeader('Deprecation', 'true');
res.setHeader(
'Link',
'<https://api.yourdomain.com/v2/users>; rel="successor-version"'
);
next();
}
app.use('/v1', deprecationHeaders, v1Router);
Any client that logs response headers (a good practice) will see this automatically, without waiting to read release notes.
Recommended timeline by API maturity:
| API age / customer count | Minimum deprecation window |
|---|---|
| Internal / prototype | 2 weeks |
| Public beta | 3 months |
| Stable / production | 6–12 months |
| Enterprise contracts | Per SLA (often 18+ months) |
What to put in your docs:
## Deprecation: /v1/users
⚠️ /v1 is deprecated as of 2026-06-01 and will be shut down 2027-01-01.
Migration guide:
- `name` → `fullName`
- `dob` → `birthDate` (same ISO-8601 string format)
Update your requests to /v2/users. Questions? api-support@yourdomain.com
Mental model: Deprecation is a public promise with a deadline. Honor it in both directions — don't pull the plug early, and don't extend indefinitely because one partner hasn't upgraded. Set the deadline, communicate clearly, execute on time.
Common mistake: "Soft deprecation" — adding a note in the changelog but no Sunset header, no deadline, no migration guide. Developers only read changelogs when something breaks; machine-readable headers run always.
🏗️ Additive-by-default mindset
The most powerful versioning strategy is avoiding breaking changes in the first place. Before you ship any API change, ask the single question: can an existing client that never changes a line of code still work correctly after this?
If yes: ship it freely. No version bump needed. If no: use expand/contract and plan a deprecation timeline.
Rules of thumb for staying additive:
// SAFE — adding a new optional field
{ ...existingShape, newOptionalField: value }
// SAFE — adding a new endpoint entirely
app.get('/v1/users/:id/preferences', handler); // new endpoint, old ones untouched
// SAFE — adding a new optional query parameter
// GET /v1/users?includeDeleted=true ← old requests without it still work
// BREAKING — changing a required field's meaning
// Before: `status: "active"` | `status: "inactive"`
// After: `status: 1` | `status: 0` ← type changed
// BREAKING — making an optional field required in the request body
// Before: POST /users { name }
// After: POST /users { name, role } ← old clients sending no role now fail validation
Mental model: Your API is a public square, not your living room. You can add benches (additive). You cannot remove the fountain everyone is using as a landmark without giving people time to update their maps.
🛠️ Your mission
You have a live /v1/users endpoint that returns:
{ "name": "Jordan", "dob": "1995-03-12", "email": "jordan@example.com" }
Your team wants to ship:
- Rename
name→fullName - Rename
dob→birthDate - Add a new
avatarUrlfield
Plan and implement the safe migration using expand/contract and URL versioning:
- Identify which changes are breaking — list each proposed change and mark it B (breaking) or A (additive). Explain your reasoning.
- Expand phase — modify your existing
/v1/users/:idserializer to include both old and new field names simultaneously. Write the updatedserializeUserfunction. - Create
/v2endpoint — implement a clean/v2/users/:idhandler that returns only the new shape (fullName,birthDate,avatarUrl). Mount it alongside/v1in your Express app. - Add deprecation headers — write the Express middleware that attaches
Sunset,Deprecation, andLinkheaders to every/v1response. Choose a sunset date 6 months from today. - Write the migration doc — draft the 8–12 line deprecation notice you would add to your API docs. Include the field mapping table and a contact email.
✅ You're done when…
- You can articulate the difference between a breaking and additive change without looking at your notes, and give two examples of each (API Design Checklist).
- Your
/v1/users/:idresponse includes bothname/dob(old) andfullName/birthDate(new) simultaneously — clients on either version get correct data. - Your
/v2/users/:idendpoint is mounted and returns only the clean V2 shape, coexisting with/v1in the same running server (API Design Checklist). - Every
/v1response carries aSunsetheader with a real future date and aLinkheader pointing to the V2 equivalent. - You have a written deprecation notice (in your docs or a markdown file) with a field-mapping table, the sunset date, and a migration path.
- You can explain expand/contract to a teammate: what the three phases are, why you need all three, and what breaks if you skip the expand phase.
➡️ Next: Rate Limiting, Idempotency & Resilience. Build It Right, Or Don't Build It At All. 🏛️