breaking changes. Use the deprecated field and x-deprecation-date extension to communicate timelines.
openapi: 3.1.0
info:
title: User Service API
version: 2.4.0
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
'200':
description: User retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required: [id, email, created_at]
properties:
id: { type: string, format: uuid }
email: { type: string, format: email }
created_at: { type: string, format: date-time }
legacy_username:
type: string
deprecated: true
x-deprecation-date: '2025-06-01'
description: 'Replaced by display_name. Will be removed in v3.0.'
Step 2: Runtime Validation with Zod
Never trust raw payloads. Validate against the contract at the boundary.
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
created_at: z.string().datetime(),
legacy_username: z.string().optional(),
});
export type User = z.infer<typeof UserSchema>;
export function validateUserPayload(raw: unknown): User {
return UserSchema.parse(raw);
}
Step 3: Consumer-Driven Contract Testing
Use Pact to verify that provider responses match consumer expectations. Contract tests run in CI and fail on incompatible changes.
import { Pact, Matchers } from '@pact-foundation/pact';
import { UserSchema } from './schemas';
const provider = new Pact({
consumer: 'mobile-app',
provider: 'user-service',
dir: './pact/pacts',
logLevel: 'warn',
});
describe('GET /users/:id', () => {
it('returns a compatible user payload', async () => {
await provider.addInteraction({
state: 'user exists',
uponReceiving: 'a request for a user',
withRequest: {
method: 'GET',
path: '/users/550e8400-e29b-41d4-a716-446655440000',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: Matchers.like({
id: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
created_at: '2024-01-15T10:30:00Z',
}),
},
});
await provider.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/users/550e8400-e29b-41d4-a716-446655440000`);
const data = await res.json();
UserSchema.parse(data); // Validates against runtime schema
});
});
});
Step 4: Deprecation Signaling at Runtime
Inject deprecation headers into responses. Consumers can parse these to trigger migration workflows.
import express from 'express';
import { validateUserPayload } from './validation';
const app = express();
app.get('/users/:id', (req, res) => {
const user = fetchUserFromDB(req.params.id);
// Signal deprecation window
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jun 2025 00:00:00 GMT');
res.set('Link', '</users/v2/${id}>; rel="successor-version"');
res.json(user);
});
Architecture Decisions and Rationale
- Contract-First over Code-First: OpenAPI as the source of truth prevents drift between documentation and implementation.
- Zod at the Boundary: Runtime validation catches schema mismatches before they reach business logic. It's faster than database-level checks and easier to maintain than custom validators.
- Pact for CDC: Contract tests run independently of integration tests. They verify that provider responses satisfy consumer expectations without requiring full environment spin-ups.
- Deprecation Headers over URL Versioning: Headers preserve a single endpoint, reduce routing complexity, and enable graceful migration. URL versioning fractures traffic routing and forces parallel maintenance of multiple code paths.
Pitfall Guide
1. Changing Field Types or Nullability
Changing string to number, or making a required field optional without consumer coordination breaks deserialization. JSON parsers treat type mismatches as fatal errors in strict mode.
Fix: Never alter existing field types. Introduce a new field, populate it alongside the old one, and deprecate the legacy field after a migration window.
2. Removing Required Fields or Renaming Without Aliases
Dropping required: [email] or renaming username to handle breaks consumers that expect the original shape.
Fix: Use response aliases during transition. Return both username and handle with identical values. Remove the alias only after the deprecation sunset date.
Changing { data: [...], meta: { total } } to a flat array or shifting cursor positions breaks pagination logic.
Fix: Treat the envelope as immutable. If pagination strategy changes, introduce a new endpoint or versioned path. Keep the existing envelope contract stable.
4. Treating Semantic Versioning as a Substitute for Testing
Bumping 2.4.0 to 3.0.0 doesn't prevent breaking changes in 2.4.1. Semantic versioning is a labeling convention, not a validation mechanism.
Fix: Automate contract testing. Version numbers are human-readable; contract tests are machine-enforceable.
5. Ignoring Idempotency and Retry Semantics
Adding new fields that trigger side effects, or changing response codes from 200 to 201, breaks retry logic in HTTP clients.
Fix: Maintain idempotency guarantees. Never change success status codes for the same operation. Document retry behavior explicitly in the contract.
6. Over-Versioning Endpoints
Creating /v1/users, /v2/users, /v3/users for every minor change fragments traffic routing and multiplies maintenance overhead.
Fix: Reserve major version bumps for architectural shifts (e.g., switching from REST to GraphQL, or changing authentication models). Use deprecation headers for field-level changes.
7. Skipping Deprecation Communication Windows
Removing a field on the same day it's marked deprecated leaves consumers with zero migration time.
Fix: Enforce a minimum deprecation window (90 days for internal APIs, 180+ for public). Automate sunset reminders via CI/CD pipelines.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Adding optional fields | Direct implementation + OpenAPI update | Non-breaking; consumers ignore unknown fields | Low |
| Renaming required fields | Alias pattern + 90-day deprecation window | Preserves backward compatibility during migration | Medium |
| Changing field types | New field + dual population + sunset | Prevents deserialization failures in strict clients | Medium |
| Altering pagination structure | New endpoint or major version | Envelope changes break cursor/offset logic | High |
| Removing deprecated fields | Automated sunset enforcement | Prevents silent failures after migration window | Low |
| Internal microservice evolution | Feature flags + contract testing | Isolates risky changes; enables rollback | Medium |
| Public API evolution | CDC + deprecation headers + developer portal | Ensures partner compliance and SLA adherence | Medium |
Configuration Template
OpenAPI Deprecation Extension:
components:
schemas:
Order:
type: object
properties:
legacy_status:
type: string
enum: [pending, shipped, delivered]
deprecated: true
x-deprecation-date: '2025-09-01'
x-replacement: 'fulfillment_state'
fulfillment_state:
type: string
enum: [processing, shipped, delivered]
CI/CD Pipeline Snippet (GitHub Actions):
name: API Compatibility Check
on: [pull_request]
jobs:
contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run pact:verify
- run: npm run openapi:lint
- name: Check Deprecation Windows
run: |
node scripts/check-deprecation-sunset.js
# Fails if x-deprecation-date is within 30 days of current date
Zod Contract Guard:
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
export function contractGuard(schema: z.ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.validatedBody = schema.parse(req.body);
next();
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
error: 'CONTRACT_VIOLATION',
details: err.errors.map(e => ({ field: e.path.join('.'), message: e.message })),
});
} else {
next(err);
}
}
};
}
Quick Start Guide
- Initialize Contract: Run
npx @apidevtools/swagger-cli bundle openapi.yaml -o dist/openapi.json to generate a validated spec.
- Generate Runtime Schemas: Use
openapi-zod-client to auto-generate Zod validators from the OpenAPI file. Run npx openapi-zod-client dist/openapi.json --output src/contracts.
- Add Deprecation Headers: Install
express-deprecation middleware or implement a custom response interceptor that reads x-deprecation-date and injects Sunset/Deprecation headers.
- Wire Contract Tests: Create a Pact provider mock in
tests/contracts/user.spec.ts. Run npm run pact:verify locally to ensure provider responses match consumer expectations.
- Gate Deployment: Add
npm run openapi:lint && npm run pact:verify to your CI pipeline. Reject merges that introduce breaking changes without corresponding deprecation windows or version bumps.
Backward compatibility isn't about freezing APIs. It's about engineering predictable evolution. Contract-first design, automated validation, and explicit deprecation signaling transform compatibility from a reactive firefight into a controlled, measurable process. Implement the lifecycle, enforce it in CI, and let consumers migrate on their terms.