---
title: "API Versioning Done Right: Gateway Patterns That Scale"
published: true
description: "Practical API versioning strategies — URL paths, header negotiation, and a gateway pattern that lets you ship breaking changes without breaking clients."
tags: api, architecture, typescript, testing
canonical_url: https://blog.mvpfactory.co/api-versioning-done-right-gateway-patterns-that-scale
---
## What We're Building
Let me show you a pattern I use in every project that needs more than one API version. We'll build a lightweight gateway layer that decouples version routing from business logic, add an adapter layer for clean schema evolution across 3+ versions, and wire up OpenAPI spec diffing in CI to catch breaking changes before they ship.
By the end, you'll have an architecture where sunsetting an old API version means deleting one transformer and one route entry. That's it.
## Prerequisites
- Node.js with Express (the pattern applies to Ktor, Fastify, or any framework)
- Basic understanding of REST API design
- An OpenAPI spec for your API (or willingness to write one)
## Step 1: Pick Your Versioning Strategy
Here's the minimal comparison you actually need:
| Strategy | Example | Cache-friendly | Tooling support |
|---|---|---|---|
| URL path | `/v2/users` | Excellent | Best — works everywhere |
| Custom header | `Accept-Version: 2` | Poor — needs Vary header | Moderate |
| Query param | `/users?version=2` | Poor — cache key pollution | Good |
URL-path versioning wins on simplicity and tooling, which is why roughly 70% of public APIs (Stripe, GitHub, Twilio) use it. Start there. But here's the gotcha that will save you hours: the strategy matters far less than the infrastructure around it.
## Step 2: Build the Gateway Layer
Instead of embedding version logic in your handlers, introduce a thin gateway. The docs don't mention this, but this single layer prevents the route sprawl that makes versioned APIs untestable.
typescript
// 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) {
return (req, res, next) => {
const version = versionExtractorsstrategy || '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);
};
}
Those `Sunset` and `Deprecation` headers follow RFC 8594 — machine-readable deprecation dates that client SDKs and monitoring tools can parse automatically. This turns deprecation from a "check the changelog" problem into an automated one.
## Step 3: Add the Adapter Layer
Schema evolution is where versioning gets painful. Here's the minimal setup to get this working — consider a real migration: renaming `userName` to `displayName` while restructuring `address` from a flat string to a nested object.
typescript
// adapters/userTransformers.ts
const transformers = {
v1: (internal) => ({
userName: internal.displayName,
address: internal.address.formatted,
}),
v2: (internal) => ({
displayName: internal.displayName,
address: internal.address.formatted,
}),
v3: (internal) => ({
displayName: internal.displayName,
address: {
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.
## Step 4: Add OpenAPI Diffing to CI
The most underused technique in API versioning is automated spec diffing. Tools like `oasdiff` compare OpenAPI specs between commits and flag breaking changes:
yaml
.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 |
I've watched this single check prevent more versioning incidents than any amount of code review.
## Gotchas
- **Don't fork controllers per version.** The moment you copy `usersV1Controller` into `usersV2Controller`, you've created a maintenance nightmare. Use the adapter pattern above instead.
- **Sunset headers cost nothing — add them from day one.** If you're not using them, you're relying on people reading emails. Good luck with that.
- **The gateway layer is an afternoon of work now vs. a week of refactoring later.** Retrofitting version routing into business logic that was never designed for it is genuinely painful.
- **Query param versioning pollutes your cache keys.** If you're behind a CDN, this matters more than you think.
## Wrapping Up
The versioning strategy you pick matters less than the infrastructure you build around it. Put a gateway layer between your routes and your business logic from day one. Add `oasdiff` to CI — it takes minutes to configure. Use Sunset headers from the start.
Invest in the gateway, the adapters, and the CI checks. That's where the real reliability comes from.
**Resources:**
- [RFC 8594 — Sunset Header](https://www.rfc-editor.org/rfc/rfc8594)
- [oasdiff — OpenAPI Diff Tool](https://github.com/Tufin/oasdiff)
- [Stripe's API Versioning Approach](https://stripe.com/docs/api/versioning)
Top comments (0)