Running Two API Versions in One Service
API Design decides the versioning policy; this is what the policy costs inside a running process, and how to pay it without forking the codebase.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
The contract has to change and old clients cannot be upgraded. How does one service serve two versions without becoming two services?
The mobile app in the field sends total as a string and expects status: "OPEN". The new contract uses minor units and lowercase enums. Both have to work for at least a year.
Copy the controllers into a v2 directory, change what needs changing, and mount them under /v2. Two directories, clean separation, no risk to existing clients.
A bug is fixed in v2/orders.ts and not in v1/orders.ts, because nobody remembered the second copy. Six weeks later the same bug is reported by an old client and reopened as a new ticket.
- A bug is fixed in
v2/orders.tsand not inv1/orders.ts, because nobody remembered the second copy. Six weeks later the same bug is reported by an old client and reopened as a new ticket. - The shared parts drift. A new authorisation rule is added to v2 handlers only, so
/v1/orders/:idstill serves objects the new rule forbids — a security regression created by a copy (Object-Level Authorization). - The copies stop being copies. Someone factors the common logic into a service, then adds
if (version === 2)branches inside it, and now the version leaks into the domain layer where it has no business being. - v1 is never removed, because no one can say who still calls it. The cost of the second version is paid forever, and it was never measured.
- Header-based versioning is chosen, and then a CDN caches a v1 response and serves it to a v2 client, because nobody set
Vary(Caching as a Contract Clause).
What is actually happening
- A version has to live somewhere a request can carry it. Three places are used in practice: the path (
/v1/orders), a header (a customAPI-Version, or a media type inAccept), and a query parameter. The choice decides who can act on the version. - Only the path is routable without parsing headers. A load balancer, a gateway, a CDN or a WAF can send
/v2/*somewhere else trivially; doing the same on a header requires that hop to inspect and understand it, and doing it on a query parameter requires it to parse the query string. - Header and media-type versioning keep URLs stable — the same resource has one address — at the cost of caching complexity (
Vary: Acceptfragments the cache and many intermediaries handle it poorly) and debuggability (you cannot paste it into a browser). - Inside the process, the real question is where the version is allowed to reach. The sustainable answer is: transport only. The router selects a version-specific request parser and response serialiser; both call the same application operation with the same domain types (Three Models, Not One).
- That works because most version differences are shape differences — a renamed field, a changed type, a flattened object, a different enum spelling. Those are mapping problems. The minority that are genuine behaviour changes need an explicit domain-level decision, not a branch buried in a mapper.
- Versioning is not the only compatibility tool and is often not the first one. Additive changes need no version at all; a deprecation window plus expand-contract on the data can carry a surprising amount of change (Expand and Contract Migrations).
Where the version lives decides who can act on it
Before the code question there is a placement question, and it is not primarily about REST purity. It is about which hops in your request path can read the version. If a gateway must route v2 to a different deployment, or a CDN must not mix the two in one cache entry, that requirement selects the placement for you.
None of these options is wrong. The one that is wrong is choosing on aesthetics and then discovering that the hop you needed cannot see the version.
Which hops need to see the version, and what do they need to do with it?
when Anything upstream needs to route, cache, rate-limit or log by version; humans need to reproduce calls by pasting a URL.
cost The same resource has multiple addresses; every link, redirect and stored URL now carries a version, and REST-minded reviewers will object.
when URLs must stay canonical and you control the clients well enough to guarantee the header is always sent.
cost Invisible in a browser; caches need Vary; a missing header forces a default policy that will surprise someone.
when You want negotiation to be part of the HTTP content-negotiation model and your clients are HTTP-literate.
cost Highest ceremony, poorest tooling support, and the same Vary and default problems as any header approach (HTTP Methods Are Promises).
when Almost never for a public API; occasionally useful as a temporary escape hatch for testing a new mapper.
cost Pollutes cache keys, is easy to drop when clients build URLs, and mixes contract selection with request filtering.
when You can guarantee every change is backward compatible: new optional fields, new endpoints, widened enums with a documented unknown-value rule (Enum Evolution: The New Value That Broke Old Clients).
cost You can never rename, tighten or remove. The contract only grows, and the accumulated cruft is permanent (Backward Compatibility: The Real Rules).
One core, versioned edges
The structural rule is short: the version is allowed to exist in routing, request parsing and response serialisation. It is not allowed anywhere below that. If a service method, a repository or a domain object can tell which version called it, the versioning has leaked and every future change costs twice.
This holds for shape changes, which are most of them. It does not hold for genuine behaviour changes — "v2 reserves stock at order time and v1 reserves at payment" is not a mapping, it is two behaviours, and it deserves either an explicit strategy object or an honest admission that you have two products.
// routes/v1/orders.ts and routes/v2/orders.ts
// both parse, both validate, both authorise, both call the DB
// a fix to one is a fix to one
app.use('/v1', v1Router)
app.use('/v2', v2Router)const MAPPERS = {
1: { parse: parseCreateOrderV1, render: renderOrderV1 },
2: { parse: parseCreateOrderV2, render: renderOrderV2 },
} as const
router.post('/:version/orders', requireAuth, async (req, res) => {
const m = MAPPERS[req.ctx.apiVersion] // resolved in middleware
const input = m.parse(req.body) // version-specific shape
if (!input.ok) return res.status(400).json(toFieldErrors(input.error))
const result = await orders.place(input.value, req.ctx.principal) // one core
return res.status(201).json(m.render(result)) // version-specific shape
})In the second form there is exactly one place where an authorisation rule, a validation rule or a bug fix lives, so a change cannot apply to one version and not the other. The mappers are pure functions over shapes, which makes them cheap to test against recorded real payloads and impossible to accidentally skip a security control in.
You cannot remove what you cannot measure
Every version you ship is a commitment to run it until you can prove nobody needs it. That proof is a metric, and the time to add it is the day the version ships — not the day someone proposes removing it.
The failure rows below are the ones that turn a one-year deprecation into a three-year one. Each is a measurement gap, not a coding mistake.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| v1 traffic looks like zero for two weeks | Removal breaks a partner's monthly reconciliation job | Usage measured over a window shorter than the client's duty cycle | Retain last-seen-per-client for at least one full billing or batch cycle before removing |
| Requests counted, callers not identified | You know 4% of traffic is v1 and not who it is | No client identity on the request | Require an API key or client id and label metrics with it (API Keys) |
| New authz rule added to v2 handlers | v1 still serves records the new rule forbids | Forked handlers; the control was added to one copy | Move authorisation below the mappers so both versions cross it (Where the Check Belongs) |
Header versioning with no Vary: Accept | A v2 client receives a cached v1 body | Intermediate cache keyed on URL alone | Set Vary correctly, or use path versioning where caching is involved (Caching as a Contract Clause) |
| Missing version header defaults to latest | Old clients break on your deploy day | Default policy chosen for convenience | Default to the oldest supported version and emit a Deprecation header on every unversioned request (Deprecation as a Process, Not a Label) |
| Sunset date announced in a changelog | Nobody who calls the API reads the changelog | Communication out of band from the request path | Signal in the response — Sunset header and a warning field — and alert clients whose traffic persists past a threshold |
How to build it
Most important first.
- Version the edge, not the core. One domain model, one service operation, N request mappers and N response mappers. The service layer should not be able to name a version (The Service Layer).
- Make the version explicit and required in the routing layer, so an unversioned request is a deliberate decision (a default with a deprecation clock) rather than an accident.
- Keep a single set of cross-cutting middleware — authentication, rate limiting, logging, error mapping — applied to every version. The most common versioning security bug is a control that exists on one version only (Middleware Ordering Is a Correctness Decision).
- Instrument version and client identity on every request from day one. You cannot run a deprecation you cannot measure (Deprecation as a Process, Not a Label).
- Write the mapper tests as contract tests against recorded real payloads from each version, not against your own current types (Contract Tests Between Services).
- Set the removal date when the version ships, and communicate it in responses — a
Deprecation/Sunsetheader costs nothing and gives client teams a signal they can automate against (Backward Compatibility: The Real Rules).
What can go wrong
- Divergence: two implementations of one operation, one of which stops receiving fixes.
- Version leaking inward:
if (v === 1)inside the service or the repository, which makes every future change a two-version change. - A shared mapper "improved" for v2 that silently changes a v1 response — the copy problem in reverse.
- Removing a field in a new version while the old version still reads it from a column the new migration dropped (Expand and Contract Migrations).
- A gateway routing on the path prefix while the application routes on a header, so the two disagree about which version ran.
- Version negotiation defaulting to *latest* when the header is absent, so every client silently upgrades on your deploy schedule instead of theirs.
- During a rolling deploy both the old and new mapper sets are live; a client can receive a v2-shaped response from one instance and a v1-shaped one from another if version selection is inconsistent across builds (Rolling Deployments).
- A deprecation cutoff applied by wall clock across many instances flips at slightly different moments, so the same client sees both behaviours for a short window. Gate on a flag you flip once, not on a timestamp each instance evaluates (Feature Flags: Rollout, Kill Switches and Debt).
- An old version is a second, less-maintained entry point to the same data. Every authorisation and validation rule added after it shipped must be re-checked against it, or the old route becomes the bypass (Defence in Depth).
- Validation relaxations are especially dangerous: if v1 accepted a field that v2 rejects, and both reach the same service, the v1 path is the way in.
- Rate limits and quotas configured per route rather than per operation leave the old version uncapped (Rate Limiting).
- Deprecated versions frequently keep older authentication mechanisms alive — an API key scheme kept "just for v1" is a credential class you are still accepting (API Keys).
- Audit logging added with v2 and not backported means the old path is invisible in your audit trail (Audit Logs for Privileged Actions).
- "Versioning is an API Design topic." The policy is. Running two versions in one process, keeping the security controls identical across them, and measuring usage well enough to remove one is backend work.
- "v2 means a new codebase." It means a new mapping at the edge for most changes. If it means a new codebase, the change is probably a new service or a new product decision.
- "Semantic versioning applies to APIs." Clients cannot pin a running service the way they pin a library; the only versions that exist are the ones you are serving right now.
- "Header versioning is more correct, therefore better." It is more RESTful and less operable. Both of those are real, and the trade depends on whether anything in your path needs to route or cache on version.
- "We can delete v1 when traffic reaches zero." Traffic from a monthly job is zero on twenty-nine days out of thirty.
Operating it
- Requests per second by
api_versionand by client identity. This is the single metric that makes deprecation possible; without it, removal is a guess. - A "last seen" timestamp per client per version, retained long enough to cover monthly batch integrations. Thirty days of silence is not the same as gone.
- Error rate split by version. A version with a rising error rate is usually one where a shared change broke a mapper.
- A log field naming which mapper produced the response, so a field-level bug report can be traced to a version without reproducing it.
- Count requests that arrived with no version at all — that population is the one that breaks when you change the default.
- Cost scales with the number of live versions times the number of endpoints, which is why "we will just add v3" is more expensive than it looks. Two versions is a mapping layer; five is a product.
- At high traffic, per-version metrics and logs multiply cardinality — one more label on every series (Cardinality: The Label That Took Down Monitoring).
- Versioning does not change database load, but the deprecation window does: two shapes of data must coexist for the length of the window (Expand and Contract Migrations).
- With many client teams, the bottleneck is coordination rather than code: the removal date is a negotiation, and the metric is what makes the negotiation short.
- Mapper-per-version keeps one core and costs an extra layer of types plus a mapping test suite. For a two-endpoint internal API that is more machinery than the problem deserves.
- Path versioning is the most operable and the least RESTful — the same resource now has two addresses, which matters if anything of yours resolves resources by URL.
- Header versioning keeps URLs canonical and costs cache correctness (
Vary), debuggability, and the ability to route at the edge. - Never versioning — only ever making additive, backward-compatible changes — is genuinely viable and constrains the product: you can never rename, never tighten a type, never remove a field (Removing Fields Without Removing Consumers).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALOne core with versioned edge mappers is a structural pattern independent of language and framework.
- PROTOCOL-SPECIFICMedia-type and header negotiation is an HTTP mechanism with HTTP caching consequences (
Vary). gRPC versions through package names in the.protoand relies on field-number compatibility rules, so additive change is native and renames are free at the wire level; GraphQL conventionally deprecates fields in the schema rather than versioning the endpoint. The trade-offs move substantially between these. - SCALE-SPECIFICWith one first-party client you control, coordinated deploys beat versioning entirely. Versioning earns its cost when clients ship on someone else's schedule — mobile apps, partner integrations, public APIs.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Distributed Systems — running two contract versions across many services at once, where the deprecation window is bounded by the slowest team rather than by your deploy.