API Versioning & Schema Evolution: URL vs Header Versioning, Backward Compatibility, Migration Strategies & Contract Testing
API Versioning & Schema Evolution: URL vs Header Versioning, Backward Compatibility, Migration Strategies & Contract Testing
Why API Versioning Matters
APIs are contracts between services. When you change a response field, rename an endpoint, or alter validation rules, every client that depends on your API can break. API versioning lets you evolve your API while maintaining backward compatibility for existing consumers.
The Breaking Change Problem:
v1: GET /users/123 → { "name": "Alice", "email": "a@b.com" }
v2: GET /users/123 → { "fullName": "Alice Smith", "emailAddress": "a@b.com" }
Mobile app v1.0 (in app stores, can't force update):
expects "name" → gets undefined → crashes
Backend service A (controlled by another team):
expects "email" → gets undefined → fails silently
Without Versioning:
Deploy v2 → break all clients simultaneously
With Versioning:
v1 clients → /v1/users/123 → old format (still works)
v2 clients → /v2/users/123 → new format
Sunset v1 after all clients migrate
Versioning Strategies Compared
Strategy 1: URL Path Versioning
────────────────────────────────
GET /v1/users/123
GET /v2/users/123
Pros: Obvious, cacheable, easy routing
Cons: Clutters URLs, hard to version individual resources
Strategy 2: Query Parameter Versioning
────────────────────────────────────────
GET /users/123?version=1
GET /users/123?version=2
Pros: Clean URLs, optional parameter
Cons: Easy to forget, cache key complexity
Strategy 3: Header Versioning (Content Negotiation)
────────────────────────────────────────────────────
GET /users/123
Accept: application/vnd.myapi.v1+json
Accept: application/vnd.myapi.v2+json
Pros: Clean URLs, follows HTTP semantics
Cons: Hidden versioning, harder to test in browser
Strategy 4: No Versioning (Additive Changes Only)
──────────────────────────────────────────────────
GET /users/123 → always backward compatible
Add fields (never remove)
Deprecate with warnings
Use feature flags for behavior changes
Pros: Simplest, no version maintenance
Cons: API grows indefinitely, hard with breaking changes
Comparison:
┌────────────────────┬───────────┬─────────────┬───────────┬──────────┐
│ │ URL Path │ Query Param │ Header │ Additive │
├────────────────────┼───────────┼─────────────┼───────────┼──────────┤
│ Discoverability │ High │ Medium │ Low │ N/A │
│ Cache Friendliness │ Excellent │ Good │ Varies │ Excellent│
│ API Gateway Support│ Native │ Native │ Custom │ N/A │
│ Client Complexity │ Low │ Low │ Medium │ Low │
│ Breaking Changes │ New path │ New param │ New header│ Not allow│
│ Maintenance Cost │ High (N │ High │ Medium │ Low │
│ │ codebases)│ │ │ │
│ Used By │ Stripe, │ Google, │ GitHub, │ Slack, │
│ │ Twitter │ Amazon │ Stripe │ GraphQL │
└────────────────────┴───────────┴─────────────┴───────────┴──────────┘
API Version Router Implementation
interface VersionedRoute {
method: string;
path: string;
versions: Map<number, RouteHandler>;
defaultVersion: number;
deprecatedVersions: Set<number>;
sunsetVersions: Map<number, string>; // version → sunset date
}
type RouteHandler = (req: APIRequest, res: APIResponse) => Promise<void>;
interface APIRequest {
method: string;
path: string;
headers: Record<string, string>;
query: Record<string, string>;
params: Record<string, string>;
body: any;
}
interface APIResponse {
status(code: number): APIResponse;
header(name: string, value: string): APIResponse;
json(data: any): void;
}
class APIVersionRouter {
private routes: VersionedRoute[] = [];
private versioningStrategy: 'url' | 'header' | 'query';
private defaultVersion: number;
constructor(config: {
strategy: 'url' | 'header' | 'query';
defaultVersion: number;
}) {
this.versioningStrategy = config.strategy;
this.defaultVersion = config.defaultVersion;
}
register(
method: string,
path: string,
version: number,
handler: RouteHandler,
options?: { deprecated?: boolean; sunsetDate?: string }
): void {
let route = this.routes.find(r => r.method === method && r.path === path);
if (!route) {
route = {
method,
path,
versions: new Map(),
defaultVersion: this.defaultVersion,
deprecatedVersions: new Set(),
sunsetVersions: new Map()
};
this.routes.push(route);
}
route.versions.set(version, handler);
if (options?.deprecated) {
route.deprecatedVersions.add(version);
}
if (options?.sunsetDate) {
route.sunsetVersions.set(version, options.sunsetDate);
}
}
async handle(req: APIRequest, res: APIResponse): Promise<void> {
// 1. Extract version from request
const version = this.extractVersion(req);
// 2. Find matching route
const route = this.findRoute(req.method, req.path);
if (!route) {
res.status(404).json({ error: 'Not Found' });
return;
}
// 3. Check if version exists
const handler = route.versions.get(version);
if (!handler) {
// Return available versions
const available = [...route.versions.keys()].sort();
res.status(400).json({
error: `API version ${version} not available for this endpoint`,
availableVersions: available,
latestVersion: Math.max(...available)
});
return;
}
// 4. Add deprecation headers if applicable
if (route.deprecatedVersions.has(version)) {
res.header('Deprecation', 'true');
res.header('Link', `</v${route.defaultVersion}${req.path}>; rel="successor-version"`);
const sunsetDate = route.sunsetVersions.get(version);
if (sunsetDate) {
res.header('Sunset', new Date(sunsetDate).toUTCString());
}
}
// 5. Add version header to response
res.header('X-API-Version', String(version));
// 6. Execute versioned handler
await handler(req, res);
}
private extractVersion(req: APIRequest): number {
switch (this.versioningStrategy) {
case 'url': {
// Extract from URL: /v2/users/123
const match = req.path.match(/^\/v(\d+)/);
return match ? parseInt(match[1], 10) : this.defaultVersion;
}
case 'header': {
// Extract from Accept header: application/vnd.myapi.v2+json
const accept = req.headers['accept'] || '';
const match = accept.match(/vnd\.myapi\.v(\d+)/);
return match ? parseInt(match[1], 10) : this.defaultVersion;
}
case 'query': {
// Extract from query: ?version=2
const v = req.query['version'];
return v ? parseInt(v, 10) : this.defaultVersion;
}
default:
return this.defaultVersion;
}
}
private findRoute(method: string, path: string): VersionedRoute | undefined {
// Strip version prefix from URL path versioning
const cleanPath = path.replace(/^\/v\d+/, '');
return this.routes.find(r =>
r.method === method && this.matchPath(r.path, cleanPath)
);
}
private matchPath(pattern: string, path: string): boolean {
// Simple path matching with params like /users/:id
const patternParts = pattern.split('/');
const pathParts = path.split('/');
if (patternParts.length !== pathParts.length) return false;
return patternParts.every((part, i) =>
part.startsWith(':') || part === pathParts[i]
);
}
}
Schema Evolution: Backward & Forward Compatibility
Backward Compatible Changes (SAFE):
─────────────────────────────────
✅ Add a new optional field
✅ Add a new endpoint
✅ Add a new enum value (if clients handle unknown values)
✅ Add optional query parameters
✅ Widen a type (int32 → int64)
✅ Add a new response header
Backward Incompatible Changes (BREAKING):
─────────────────────────────────────────
❌ Remove a field
❌ Rename a field
❌ Change a field's type
❌ Change the URL path
❌ Make an optional field required
❌ Change error response format
❌ Remove an enum value
❌ Change authentication mechanism
❌ Narrow a type (int64 → int32)
Evolution Strategy: Transform Layer
v1 clients v2 clients
│ │
▼ ▼
┌───────────┐ ┌───────────┐
│ v1→v2 │ │ Passthrough│
│ Transform │ │ │
└─────┬─────┘ └─────┬─────┘
│ │
└───────┬───────────────┘
│
▼
┌────────────┐
│ v2 Handler │ ← Only ONE version of business logic
│ (canonical)│
└────────────┘
Instead of maintaining separate codebases for v1 and v2,
maintain transforms that convert between versions.
Schema Evolution with Transform Layers
interface SchemaTransform {
fromVersion: number;
toVersion: number;
transformRequest: (req: any) => any;
transformResponse: (res: any) => any;
}
class SchemaEvolutionManager {
private transforms: SchemaTransform[] = [];
private currentVersion: number;
constructor(currentVersion: number) {
this.currentVersion = currentVersion;
}
addTransform(transform: SchemaTransform): void {
this.transforms.push(transform);
// Sort to enable chaining: v1→v2, v2→v3, etc.
this.transforms.sort((a, b) => a.fromVersion - b.fromVersion);
}
// Transform a request from clientVersion to currentVersion
upgradeRequest(request: any, clientVersion: number): any {
let data = request;
let version = clientVersion;
while (version < this.currentVersion) {
const transform = this.transforms.find(
t => t.fromVersion === version && t.toVersion === version + 1
);
if (!transform) {
throw new Error(`No transform from v${version} to v${version + 1}`);
}
data = transform.transformRequest(data);
version++;
}
return data;
}
// Transform a response from currentVersion to clientVersion
downgradeResponse(response: any, clientVersion: number): any {
let data = response;
let version = this.currentVersion;
while (version > clientVersion) {
const transform = this.transforms.find(
t => t.fromVersion === version - 1 && t.toVersion === version
);
if (!transform) {
throw new Error(`No transform from v${version} to v${version - 1}`);
}
data = transform.transformResponse(data);
version--;
}
return data;
}
}
// Example: User API evolution
const manager = new SchemaEvolutionManager(3);
// v1 → v2: "name" split into "firstName" + "lastName"
manager.addTransform({
fromVersion: 1,
toVersion: 2,
transformRequest: (req) => {
if (req.name) {
const [firstName, ...rest] = req.name.split(' ');
return { ...req, firstName, lastName: rest.join(' '), name: undefined };
}
return req;
},
transformResponse: (res) => {
// v2 response → v1 response
return {
...res,
name: `${res.firstName} ${res.lastName}`.trim(),
firstName: undefined,
lastName: undefined
};
}
});
// v2 → v3: "email" renamed to "emailAddress", added "verified" field
manager.addTransform({
fromVersion: 2,
toVersion: 3,
transformRequest: (req) => {
if (req.email) {
return { ...req, emailAddress: req.email, email: undefined };
}
return req;
},
transformResponse: (res) => {
// v3 response → v2 response (drop new fields)
return {
...res,
email: res.emailAddress,
emailAddress: undefined,
verified: undefined // v2 doesn't know about this field
};
}
});
Contract Testing
Contract Testing Flow:
Consumer (Client) Provider (API Server)
┌─────────────────┐ ┌─────────────────────┐
│ "I expect GET │ │ │
│ /users/123 to │ Contract │ "I serve GET │
│ return { │ (shared) │ /users/123 with │
│ name: string, │◄──────────────►│ { │
│ email: string │ │ name: string, │
│ }" │ │ email: string, │
│ │ │ age?: number │
└─────────────────┘ │ }" │
└─────────────────────┘
Consumer tests: "Does my client handle the contract correctly?"
Provider tests: "Does my API still satisfy the contract?"
If provider removes "email", provider tests FAIL before deployment.
The contract catches breaking changes BEFORE they reach production.
interface ContractExpectation {
method: string;
path: string;
requestBody?: Record<string, FieldExpectation>;
responseStatus: number;
responseBody: Record<string, FieldExpectation>;
headers?: Record<string, string>;
}
interface FieldExpectation {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
required: boolean;
nullable?: boolean;
format?: string; // "email", "date-time", "uuid"
enum?: any[];
items?: FieldExpectation; // For arrays
properties?: Record<string, FieldExpectation>; // For objects
}
class ContractValidator {
private contracts: ContractExpectation[] = [];
addContract(contract: ContractExpectation): void {
this.contracts.push(contract);
}
// Validate that an actual API response satisfies the contract
validateResponse(
method: string,
path: string,
statusCode: number,
body: any
): ValidationResult {
const contract = this.contracts.find(
c => c.method === method && this.pathMatches(c.path, path)
);
if (!contract) {
return { valid: false, errors: [`No contract found for ${method} ${path}`] };
}
const errors: string[] = [];
// Check status code
if (statusCode !== contract.responseStatus) {
errors.push(`Expected status ${contract.responseStatus}, got ${statusCode}`);
}
// Validate response body against contract
this.validateObject(body, contract.responseBody, 'response', errors);
return { valid: errors.length === 0, errors };
}
private validateObject(
actual: any,
expected: Record<string, FieldExpectation>,
path: string,
errors: string[]
): void {
// Check required fields exist
for (const [field, expectation] of Object.entries(expected)) {
const value = actual?.[field];
const fieldPath = `${path}.${field}`;
if (expectation.required && (value === undefined || value === null)) {
if (!(expectation.nullable && value === null)) {
errors.push(`${fieldPath}: required field missing`);
continue;
}
}
if (value === undefined || value === null) continue;
// Type check
const actualType = Array.isArray(value) ? 'array' : typeof value;
if (actualType !== expectation.type) {
errors.push(`${fieldPath}: expected ${expectation.type}, got ${actualType}`);
continue;
}
// Enum check
if (expectation.enum && !expectation.enum.includes(value)) {
errors.push(`${fieldPath}: value ${value} not in enum [${expectation.enum}]`);
}
// Nested object
if (expectation.type === 'object' && expectation.properties) {
this.validateObject(value, expectation.properties, fieldPath, errors);
}
// Array items
if (expectation.type === 'array' && expectation.items && Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (expectation.items.type === 'object' && expectation.items.properties) {
this.validateObject(
value[i], expectation.items.properties, `${fieldPath}[${i}]`, errors
);
}
}
}
}
}
// Detect breaking changes between two contract versions
detectBreakingChanges(
oldContract: ContractExpectation,
newContract: ContractExpectation
): BreakingChange[] {
const changes: BreakingChange[] = [];
// Check removed fields
for (const [field, expectation] of Object.entries(oldContract.responseBody)) {
if (!(field in newContract.responseBody)) {
if (expectation.required) {
changes.push({
type: 'FIELD_REMOVED',
field,
severity: 'breaking',
message: `Required field '${field}' was removed`
});
} else {
changes.push({
type: 'FIELD_REMOVED',
field,
severity: 'warning',
message: `Optional field '${field}' was removed`
});
}
}
}
// Check type changes
for (const [field, newExp] of Object.entries(newContract.responseBody)) {
const oldExp = oldContract.responseBody[field];
if (oldExp && oldExp.type !== newExp.type) {
changes.push({
type: 'TYPE_CHANGED',
field,
severity: 'breaking',
message: `Field '${field}' type changed from ${oldExp.type} to ${newExp.type}`
});
}
// Optional → required
if (oldExp && !oldExp.required && newExp.required) {
changes.push({
type: 'REQUIRED_ADDED',
field,
severity: 'breaking',
message: `Field '${field}' changed from optional to required`
});
}
}
// Check path change
if (oldContract.path !== newContract.path) {
changes.push({
type: 'PATH_CHANGED',
field: 'path',
severity: 'breaking',
message: `Path changed from ${oldContract.path} to ${newContract.path}`
});
}
return changes;
}
private pathMatches(pattern: string, actual: string): boolean {
const patternParts = pattern.split('/');
const actualParts = actual.split('/');
if (patternParts.length !== actualParts.length) return false;
return patternParts.every((p, i) => p.startsWith(':') || p === actualParts[i]);
}
}
interface ValidationResult {
valid: boolean;
errors: string[];
}
interface BreakingChange {
type: string;
field: string;
severity: 'breaking' | 'warning' | 'info';
message: string;
}
API Deprecation Pipeline
Deprecation Timeline:
Phase 1: Announce (Month 0)
- Add Deprecation header to v1 responses
- Add Sunset header with retirement date
- Update documentation
- Email API consumers
Phase 2: Monitor (Month 0-6)
- Track v1 usage per consumer
- Send migration reminders
- Provide migration guides
Phase 3: Throttle (Month 6-9)
- Add rate limits to v1 (slower than v2)
- Log warnings for heavy v1 users
- Direct support outreach
Phase 4: Sunset (Month 9-12)
- v1 returns 410 Gone
- Include Link header to v2 documentation
- Keep error response for 3 more months
Phase 5: Remove (Month 12+)
- Delete v1 code and routes
- Return 404 for v1 paths
Response Headers During Deprecation:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Mar 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"
X-API-Version: 1
X-API-Deprecation-Notice: v1 will be removed on March 1, 2027. Migrate to v2.
Comparing Versioning Approaches
┌──────────────────┬───────────────┬───────────────┬───────────────┬───────────────┐
│ Aspect │ URL Path │ Header │ Query Param │ GraphQL │
├──────────────────┼───────────────┼───────────────┼───────────────┼───────────────┤
│ Routing │ Simple │ Custom logic │ Simple │ N/A (schema) │
│ Caching │ Natural │ Vary header │ Natural │ Complex │
│ Documentation │ Separate per │ Combined │ Combined │ One schema │
│ │ version │ │ │ │
│ Testing │ Easy (URL) │ Need headers │ Easy (URL) │ Field-level │
│ Breaking Changes │ New version │ New version │ New version │ Deprecate │
│ │ number │ number │ number │ fields │
│ Code Maintenance │ N copies or │ Transform │ Transform │ One resolvers │
│ │ transforms │ layers │ layers │ set │
│ Sunset Process │ Remove old │ Remove old │ Remove old │ Remove fields │
│ │ prefix │ handler │ handler │ (hard!) │
│ Real-World Use │ Stripe, X │ GitHub │ Google Maps │ GitHub, │
│ │ │ │ │ Shopify │
└──────────────────┴───────────────┴───────────────┴───────────────┴───────────────┘
Interview Questions
Q1: URL path versioning vs header versioning — when would you choose each?
URL path versioning (/v2/users) is the right default for most public APIs. It's immediately visible, trivially cacheable (the URL is the cache key), easy to test in a browser, and API gateways route on path natively. Stripe, Twilio, and most public APIs use this. The downside is proliferating URL namespaces and making it hard to version individual fields rather than entire endpoints. Header versioning (Accept: application/vnd.myapi.v2+json) is better for APIs where versioning should be transparent — the URL stays clean and represents the resource, while the version controls the representation. GitHub uses this approach. It follows HTTP content negotiation semantics correctly. But it's harder to discover (consumers must read docs), harder to test (can't just paste a URL), and requires Vary: Accept headers for caching. For internal microservices, header versioning or even no versioning (additive only) is often better since you control all consumers.
Q2: How do you handle a field rename without breaking existing clients?
Never remove the old field and add a new one simultaneously. Instead, use a migration pattern: (1) Phase 1 — Add: Add the new field (emailAddress) alongside the old field (email). Both return the same value. The new field is documented as preferred. Your API now returns { "email": "a@b.com", "emailAddress": "a@b.com" }. (2) Phase 2 — Accept both on input: For write endpoints, accept both field names. If both are provided, the new name takes precedence. (3) Phase 3 — Deprecate: Mark the old field as deprecated in docs and response headers. Add a response header: X-Deprecated-Fields: email. Monitor usage of the old field. (4) Phase 4 — Remove: After all consumers have migrated (verified via analytics), stop including the old field. Alternatively, if you use versioned transforms: the v2 handler uses emailAddress internally. The v1→v2 transform maps email to emailAddress on requests and maps it back on responses. Only one version of business logic exists.
Q3: How do you maintain backward compatibility in an event-driven architecture where multiple services consume the same events?
Events are harder to version than REST APIs because you can't use URL paths and consumers are decoupled. Strategies: (1) Schema registry: Use Avro, Protobuf, or JSON Schema with a central registry. The registry enforces compatibility rules — forward-compatible (new producer, old consumer), backward-compatible (old producer, new consumer), or full compatibility. (2) Additive changes only: Add new fields but never remove or rename existing ones. Consumers ignore unknown fields (tolerant reader pattern). (3) Event versioning: Include a version field in every event: { "version": 2, "type": "order.created", ... }. Consumers dispatch by version. (4) Parallel event types: For breaking changes, publish a new event type (order.created.v2) alongside the old one. Both are published during the migration window. Consumers migrate to the new type at their own pace. The producer stops publishing the old type after all consumers have migrated. (5) Consumer-driven contracts: Each consumer publishes the fields they depend on. CI checks that producer changes don't remove those fields.
Q4: What is the "tolerant reader" pattern and how does it relate to API evolution?
The tolerant reader pattern means consumers should ignore fields they don't recognize and not fail when optional fields are missing. This is the single most important rule for API resilience. If a client strictly validates every response field, any addition breaks it. Instead: parse only the fields you need, ignore the rest. This enables the producer to add fields freely, reorder fields, or include experimental fields without coordinating with consumers. Implementation: use a deserialization approach that discards unknown properties (most JSON parsers do this by default). In TypeScript, define interfaces with only the fields you use — extra fields in the JSON are silently ignored. The pattern breaks down for field removals or type changes, which require versioning. Combined with additive-only API evolution, the tolerant reader pattern eliminates the need for explicit versioning in many cases. Stripe's API philosophy is built on this — they almost never remove fields, preferring to add new ones alongside deprecated old ones.
Q5: How do you test that a new API version doesn't break existing consumers?
Contract testing at multiple levels: (1) Consumer-driven contract tests (Pact): Each consumer defines a "pact" — the requests it makes and the responses it expects. The provider runs these pacts in CI. If a new provider version breaks any consumer's pact, the build fails. This catches breaking changes before deployment. (2) Schema validation in CI: Run the breaking change detector on the old schema vs. the new schema. Flag any field removals, type changes, or optional-to-required transitions. Block the PR if breaking changes are detected without a version bump. (3) Integration tests per version: For each supported API version, maintain a test suite that hits the API and validates response schemas. When you modify v3, the v1 and v2 test suites must still pass (through the transform layers). (4) Shadow traffic: Route a copy of production traffic through the new version and compare responses. Differences indicate potential breaking changes. (5) API linting: OpenAPI/Swagger diff tools (like openapi-diff) can automatically detect incompatible changes between two spec versions.
Key Takeaways
-
URL path versioning is the pragmatic default for public APIs: Visible, cacheable, easy to route. Use header versioning when URL cleanliness matters and you control all consumers.
-
Additive changes are always safe: Add fields, add endpoints, add optional parameters. Never remove, rename, or change types in the same version.
-
Transform layers avoid maintaining parallel codebases: Write business logic once (latest version). Transform requests/responses between versions. Chain transforms for multi-version support.
-
The tolerant reader pattern is essential for resilience: Consumers should ignore unknown fields and handle missing optional fields gracefully. This makes additive API evolution non-breaking.
-
Contract testing catches breaking changes before production: Consumer-driven contracts (Pact) verify that provider changes don't break any consumer's expectations. Run them in CI.
-
Deprecation requires a timeline, not a switch: Announce → monitor usage → throttle → sunset → remove. Each phase has specific headers (Deprecation, Sunset, Link) and communication strategies.
-
Event schemas need the same versioning discipline as REST APIs: Use a schema registry with compatibility modes. Prefer additive changes. For breaking changes, publish parallel event types during migration.
-
GraphQL avoids versioning by deprecating fields individually: No need for URL versions. Mark fields as
@deprecated(reason: "Use emailAddress"). But removing deprecated fields from a live schema is still a breaking change. -
Schema validation in CI prevents accidental breaking changes: Diff the old API spec against the new one. Block PRs that remove required response fields or change types without a version bump.
-
Sunset dates must be realistic: Give consumers 6-12 months minimum. Track usage analytics to know when it's safe to remove an old version. Direct outreach to heavy consumers of deprecated versions.
What did you think?