API Versioning & Backward Compatibility
Design API versioning strategies that evolve gracefully without breaking existing consumers.
Activation Triggers
Activate on: "API versioning", "backward compatibility", "deprecation", "breaking change", "API migration", "v1 v2", "sunset header", "API evolution", "non-breaking change"
NOT for: Data schema evolution → schema-evolution-manager | Gateway version routing → api-gateway-reverse-proxy-expert | GraphQL deprecation → graphql-server-architect
Quick Start
- Classify the change — additive (safe), modification (maybe breaking), removal (breaking)
- Choose strategy — URL path (
/v2/), header (API-Version), or content negotiation - Implement Sunset headers — RFC 8594 tells consumers when old versions die
- Run versions in parallel — minimum 6-month overlap for major versions
- Monitor adoption — track per-version traffic to know when to retire
Core Capabilities
| Domain | Technologies |
|---|---|
| URL Versioning | /api/v1/, /api/v2/ path-based routing |
| Header Versioning | API-Version: 2024-01-15, Accept-Version |
| Content Negotiation | Accept: application/vnd.myapi.v2+json |
| Deprecation | Sunset header (RFC 8594), Deprecation header |
| Tooling | OpenAPI 3.1 overlays, Optic, Bump.sh |
Architecture Patterns
Versioning Strategy Decision Tree
Is it additive only? (new fields, new endpoints)
├─ YES → No version bump needed (backward compatible)
└─ NO → Is it a field rename/type change?
├─ YES → Can you keep both old + new fields?
│ ├─ YES → Add new, deprecate old (minor version)
│ └─ NO → Major version bump (v1 → v2)
└─ NO → Is it a removal?
└─ YES → Major version bump with sunset period
Parallel Version Deployment
// Express router with version-based routing
import { Router } from 'express';
const v1Router = Router();
const v2Router = Router();
// v1: returns { name: string }
v1Router.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.json({ name: user.fullName }); // legacy shape
});
// v2: returns { firstName, lastName, displayName }
v2Router.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.json({
firstName: user.firstName,
lastName: user.lastName,
displayName: user.fullName,
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Sunset header middleware for v1
v1Router.use((req, res, next) => {
res.set('Sunset', 'Sat, 01 Nov 2026 00:00:00 GMT');
res.set('Deprecation', 'true');
res.set('Link', '</api/v2>; rel="successor-version"');
next();
});
Date-Based Versioning (Stripe Model)
API-Version: 2026-03-15
Changes are tied to dates, not integers:
2026-01-01 → baseline
2026-03-15 → renamed `name` → `display_name`
2026-06-01 → removed `legacy_field`
Server pins unversioned requests to the account's default version.
Each version is a transform layer over the canonical internal model.
Anti-Patterns
- Versioning too eagerly — most changes are additive and do not require a version bump; add fields freely
- No sunset timeline — deprecating without a concrete shutdown date means versions live forever
- Copying entire controllers — use a transform/adapter layer, not duplicated business logic per version
- Ignoring unknown fields — APIs should be tolerant readers; ignore unknown fields on input (Postel's Law)
- Breaking changes in minor versions — if consumers relied on it, removing it is breaking regardless of your intent
Quality Checklist
- Additive changes (new fields, new endpoints) ship without version bump
- Breaking changes require major version with 6+ month sunset period
- Sunset header (RFC 8594) set on deprecated versions
- Link header points consumers to successor version
- Per-version traffic monitored to track migration progress
- OpenAPI spec published per version with diff tooling (Optic/Bump.sh)
- Consumer notification plan: changelog, email, dashboard warning
- Version routing handled at gateway level, not in application code
- Integration tests validate backward compatibility (contract tests)