.github/workflows/api-compat.yml
The versioning problem nobody solves well
Most teams pick an API versioning strategy in week one and regret it by month six. The initial choice — URL segments, headers, or query params — matters far less than the infrastructure around it: how you route requests, transform payloads, and deprecate old versions without surprise outages.
Most teams treat versioning as a URL design problem when it’s actually an architecture problem. That distinction makes all the difference.
Comparing the three approaches
| Strategy | Example | Discoverability | Cache-friendliness | Tooling support |
|---|---|---|---|---|
| URL path | /v2/users | High, visible in URL | Excellent, unique cache keys | Best, works everywhere |
| Custom header | Accept-Version: 2 | Low, hidden in headers | Poor, requires Vary header | Moderate, needs docs |
| Query param | /users?version=2 | Medium | Poor, cache key pollution | Good, easy to test |
URL-path versioning wins on simplicity and tooling, which is why roughly 70% of public APIs (Stripe, GitHub, Twilio) use it. But it creates a real problem: every version multiplies your route definitions, and your controllers accumulate conditional logic that becomes untestable.
A gateway pattern worth using
Instead of embedding version logic in your handlers, introduce a thin gateway layer that does a few things. It extracts the version from URL, header, or query param (so you’re not locked to one strategy). It routes to a versioned handler chain with request/response transformers. And it injects sunset headers for deprecated versions.
A minimal implementation in Express:
// gateway/versionRouter.ts
const versionExtractors = {
path: (req) => req.params.version,
header: (req) => req.headers['accept-version'],
query: (req) => req.query.version,
};
function versionGateway(strategy: string, handlers: Record<string, Handler>) {
return (req, res, next) => {
const version = versionExtractors[strategy](req) || 'v3';
const handler = handlers[version];
if (!handler) return res.status(410).json({ error: 'Version sunset' });
if (handler.sunset) {
res.set('Sunset', handler.sunset);
res.set('Deprecation', 'true');
res.set('Link', `</v3/users>; rel="successor-version"`);
}
return handler.fn(req, res, next);
};
}
This keeps your actual business logic version-free. Each handler chain includes an adapter layer that transforms between the client’s expected schema and your internal domain model.
The adapter layer: supporting 3+ versions cleanly
Schema evolution is where versioning gets painful. Consider a real migration: renaming userName to displayName while restructuring address from a flat string to a nested object.
// adapters/userTransformers.ts
const transformers = {
v1: (internal) => ({
userName: internal.displayName, // field rename
address: internal.address.formatted, // flatten nested object
}),
v2: (internal) => ({
displayName: internal.displayName,
address: internal.address.formatted, // still flat in v2
}),
v3: (internal) => ({
displayName: internal.displayName,
address: { // full nested structure
street: internal.address.street,
city: internal.address.city,
country: internal.address.country,
},
}),
};
Your internal domain model evolves freely. The transformers are the only place where version-specific logic lives — small, testable, and isolated. When you sunset v1, you delete one transformer and one route entry. That’s it.
Catching breaking changes in CI with OpenAPI diffing
The most underused technique in API versioning is automated spec diffing. Tools like oasdiff compare OpenAPI specs between commits and flag breaking changes:
# .github/workflows/api-compat.yml
- name: Check API compatibility
run: |
oasdiff breaking openapi-prev.yaml openapi-current.yaml \
--fail-on ERR
What this catches:
| Change type | Example | Severity |
|---|---|---|
| Field removed | userName deleted without deprecation | Error |
| Type changed | age: string -> age: integer | Error |
| Required field added | New required email on request body | Error |
| Enum value removed | Status PENDING dropped | Warning |
This is your safety net. Teams that add spec diffing to CI catch breaking changes before merge, not after a client integration breaks in production. I’ve watched this single check prevent more versioning incidents than any amount of code review.
Sunset headers: deprecation clients actually see
RFC 8594 defines the Sunset header, a machine-readable deprecation date. Pair it with the Deprecation header and a Link to the successor version. Client SDKs and monitoring tools can parse these and alert teams before a version goes dark.
This turns deprecation from a “check the changelog” problem into an automated one. And honestly, if you’re not doing this, you’re relying on people reading emails. Good luck with that.
What I’d do on a new project
Start with URL-path versioning. It’s boring, it works, and every HTTP tool on the planet understands it. But don’t stop there.
Put a gateway layer between your routes and your business logic from day one. When you inevitably need to support a second version, you’ll add a transformer instead of forking your controller. The effort to set this up is maybe an afternoon; the effort to retrofit it later is a week of careful refactoring.
Add oasdiff to your CI pipeline. It takes minutes to configure and it will save you from the 2 AM “why did our partner’s integration break” call.
Use Sunset headers from the start. They cost nothing and they let your consumers’ tooling do the work of tracking deprecation timelines.
The versioning strategy you pick matters less than the infrastructure you build around it. Invest in the gateway, the adapters, and the CI checks. That’s where the real reliability comes from.
Tags: api rest architecture backend nodejs