Running Two API Versions Side by Side
Problem: you have shipped v2, v1 is deprecated, and you now have to keep both working for a two-quarter migration window without doubling your maintenance cost or your incident surface. This guide is part of API Versioning and Deprecation Policies and covers the mechanics of the overlap period specifically.
The single decision that determines whether this is cheap or expensive: is the old version a projection of the current implementation, or a second implementation? Everything below follows from choosing the first.
The Shape That Works
One process, one domain model, one storage schema. Two routers, two serializers, and nothing else duplicated.
Step 1: Mount a Router Per Version
// app.ts — Express 4
import express from "express";
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";
import { deprecation } from "./middleware/deprecation";
const app = express();
app.use(express.json());
// The deprecation signal is attached to the whole v1 surface at once.
app.use("/v1", deprecation({
deprecatedAt: new Date("2026-08-01T00:00:00Z"),
sunsetAt: new Date("2027-03-01T00:00:00Z"),
guide: "https://docs.example.com/v2-migration",
}), v1Router);
app.use("/v2", v2Router);
// Anything unversioned is a client that predates the scheme — answer explicitly.
app.use("/orders", (_req, res) =>
res.status(400).json({ error: "specify a version: /v1 or /v2" }));
The unversioned fallback matters more than it looks. Without it, a legacy caller gets a 404 and files a bug about your service being down.
Step 2: Write the Translation Layer
The translation layer is a pure function from the current domain object to the older wire shape. Keeping it pure is what makes it testable and what stops it acquiring logic.
// routes/v1/serializers.ts — pure, no I/O, no service calls
import type { Order } from "../../domain/order";
// v1 sent a bare numeric total plus a separate currency field.
// v2 sends a Money object. The domain model uses Money.
export function toV1Order(order: Order) {
return {
id: order.id,
status: v1Status(order.status),
total: order.total.amount,
currency: order.total.currency,
created_at: order.createdAt.toISOString(), // v1 used snake_case
};
}
// v2 added a 'settling' status that v1 clients have never seen.
// Collapse it onto the nearest v1 concept rather than inventing a value.
function v1Status(status: Order["status"]): string {
return status === "settling" ? "processing" : status;
}
Why this works: the mapping is total — every domain value produces a legal v1 value — so a new status added to the domain model forces a compile error here rather than leaking an unknown string to a v1 client.
The reverse direction, for requests, follows the same rule:
// A v1 create request omits 'channel', which v2 requires. Supply the documented default.
export function fromV1CreateRequest(body: V1CreateBody): CreateOrderCommand {
return {
customerId: body.customer_id,
total: { amount: body.total, currency: body.currency },
channel: "web", // documented in the v1 spec as the implicit default
};
}
Step 3: Keep One Storage Schema
The tempting shortcut during an overlap is to leave the old columns in place for v1 and add new ones for v2. It works for one release and then compounds: two write paths, two sets of constraints, and a reconciliation job nobody wants to own.
Migrate forward instead, and let the domain layer present the old shape:
-- Migration: split the old numeric total into an amount and a currency.
ALTER TABLE orders ADD COLUMN total_currency CHAR(3) NOT NULL DEFAULT 'USD';
ALTER TABLE orders RENAME COLUMN total TO total_amount;
-- A view keeps ad-hoc queries and reporting working through the transition.
CREATE VIEW orders_v1 AS
SELECT id, status, total_amount AS total, total_currency AS currency, created_at
FROM orders;
Step 4: Test Each Version’s Contract, Not Its Behaviour
Duplicating the whole test suite per version is how teams end up believing the overlap is expensive. It is not, if the tests are layered correctly:
// tests/v1-serialization.test.ts — the ONLY v1-specific tests
import { toV1Order } from "../src/routes/v1/serializers";
const order = {
id: "ord_42", status: "settling" as const,
total: { amount: 4200, currency: "EUR" },
createdAt: new Date("2026-07-01T10:00:00Z"),
};
test("v1 flattens money and maps the unknown status", () => {
expect(toV1Order(order)).toEqual({
id: "ord_42",
status: "processing", // 'settling' does not exist in v1
total: 4200,
currency: "EUR",
created_at: "2026-07-01T10:00:00.000Z",
});
});
Everything else — business rules, persistence, authorization — is tested once against the domain layer. Consumer expectations for both versions are then covered by provider verification, which replays each consumer’s recorded interactions against the running service regardless of which version they call.
Verification
# Both versions answer, with the shapes their contracts promise.
curl -s localhost:3000/v1/orders/ord_42 | jq -c 'keys'
# ["created_at","currency","id","status","total"]
curl -s localhost:3000/v2/orders/ord_42 | jq -c 'keys'
# ["createdAt","id","status","total"]
# Only v1 carries the deprecation signal.
curl -sD - localhost:3000/v1/orders/ord_42 -o /dev/null | grep -ci '^sunset' # 1
curl -sD - localhost:3000/v2/orders/ord_42 -o /dev/null | grep -ci '^sunset' # 0
# A write through v1 is visible through v2 — proof of one storage path.
ID=$(curl -s -XPOST localhost:3000/v1/orders -H 'content-type: application/json' \
-d '{"customer_id":"cus_1","total":999,"currency":"USD"}' | jq -r .id)
curl -s "localhost:3000/v2/orders/$ID" | jq -c '.total'
# {"amount":999,"currency":"USD"}
That last check is the one that matters most: if a v1 write is not readable through v2, the versions have separate storage paths and the overlap has already become expensive.
Edge Cases and Caveats
- Rate limits and quotas. Decide whether the two versions share a quota. Sharing is usually right — the caller is one integration — but it must be a decision, because per-version limits let a migrating client accidentally double its allowance.
- Idempotency keys. If v1 and v2 have separate key namespaces, a client retrying across the migration can duplicate a write. Keep one namespace and one key store.
- Authentication differences. If v2 introduced a new auth scheme, the v1 router still has to accept the old one. Keep both verifiers registered until v1 is retired, and log which scheme each caller uses — it is another retirement signal.
- Observability. Tag metrics and traces with the version. Without it, a latency regression that only affects v1’s translation layer is invisible in aggregate dashboards.
Deciding When the Overlap Should End
An overlap has a cost that grows quietly: two serializers to keep correct, two sets of contract expectations to verify, and a translation layer that every new domain field has to pass through. The decision to end it should be driven by evidence rather than by fatigue.
Three signals say the overlap has served its purpose. Traffic to the old version has been zero from identified consumers across a full business cycle, including monthly and quarterly jobs. No consumer has an open migration ticket, which is a different question from whether traffic stopped — a consumer may have migrated one code path and not another. And the translation layer has stopped changing, meaning the domain model and the old wire shape are no longer diverging in ways anyone needs.
Two signals say to extend it instead. A consumer with a genuine constraint — a mobile release train, a partner’s own change freeze — is a reason to extend once, with a new published date. And a spike of newly-appearing v1 traffic usually means someone deployed an old build, which is worth understanding before you remove the endpoint they depend on.
When the overlap does end, remove it completely in one change: the router, the serializers, the version-specific tests, and the specification document for the retired version. A half-removed version leaves dead code that the next engineer cannot safely delete, because they cannot tell whether it is still reachable.
Frequently Asked Questions
Should the two versions be separate deployments?
Only when they genuinely need different scaling, different dependencies, or different security boundaries. One deployment with two routers is cheaper to operate and makes it structurally impossible for a bug fix to land in one version and not the other.
Where should the translation layer live?
As close to the transport as possible — in the route handler or a serializer module bound to it. The moment translation logic reaches into the service layer, the two versions start diverging in behaviour rather than only in shape.
How do I test both versions without duplicating every test?
Test the domain layer once, and add a thin per-version test that asserts only the serialization contract: given a known domain object, this version emits exactly this JSON. Contract verification against consumer expectations then covers the rest.
What about database migrations that only the new version needs?
Migrate the schema forward and keep the old version reading through a compatibility view or a mapping in the domain layer. Never fork the storage schema per API version — that is the point of no return for maintenance cost.
How do I handle a field that is required in v2 but absent in v1?
Give it a defined default in the translation layer for v1 requests and document the default in the v1 specification. If no sensible default exists, that is a sign the change is genuinely breaking in a way v1 cannot express, and v1 should reject the operation instead.