






















You rename a field from user_name to username because the rest of the codebase uses camelCase. The CI passes. The deploy goes green. Two hours later, support tickets start rolling in: the iOS app crashes on launch, the partner integration is returning nulls, and the billing pipeline just charged somebody twice because it parsed a renamed field as missing.
The three lines of rename cost more than the feature that made you rename it. And the root cause is not “we should have caught it in testing.” The root cause is that you changed a contract that other code depended on, and you gave those clients no way to upgrade at their own pace.
API versioning is the answer, but most teams pick a strategy by copying whatever the SaaS provider they admire uses. That is not a strategy. It is cargo-culting. URL versioning (/v1/users), header versioning (Accept: application/vnd.example.v2+json), and query-parameter versioning (/users?version=2) each impose different costs on your producers, your consumers, and your infrastructure. The right choice depends on who your clients are and how you deploy.
This post walks through each strategy with real trade-offs, gives you the backward-compatibility rules that prevent “versioning” from being a false promise, and ends with a deprecation playbook you can copy into your next project.
Before you pick a strategy, you need two things that have nothing to do with URLs or headers:
1. A written policy for what counts as a breaking change. Without this, versioning is theater. Your team will disagree on whether adding a field is breaking (it is not, for JSON APIs), whether reordering enum values is breaking (it is, for serialized code), and whether changing a 404 to a 403 is breaking (it is, for clients that parse status codes). Write it down. Enforce it in code review.
2. A way to run multiple versions simultaneously. You cannot version your API if your deployment model does not support running two versions of the same endpoint at the same time. That sounds obvious. It is also the thing teams discover six months in, when their monolith cannot serve /v1/users and /v2/users from the same process because the routing is hardcoded.
GET /v1/users/42
GET /v2/users/42
This is the most common strategy, used by Stripe, Twilio, GitHub, and most public APIs. The version is part of the path. The router dispatches to different handler code based on that prefix.
What it costs you:
What it buys you:
/v1/users/42 and /v2/users/42 separately. The cache key is the URL.When to use it: Public APIs with external clients that you cannot upgrade unilaterally, especially mobile apps whose users do not update immediately. The discoverability and caching wins matter more than the code duplication cost.
Accept: application/vnd.example.v2+json
# or
X-API-Version: 2
The version is negotiated through content-type headers or a custom header. The URL stays clean.
What it costs you:
What it buys you:
/users/42 is a noun whose representation should be negotiated, this is the theoretically pure approach.When to use it: Internal APIs where you control both the client and the server, and where you care about URL aesthetics. Also useful for APIs where the client is a browser and you want to keep URLs bookmarkable across versions.
Do not use the custom header approach (X-API-Version) for public APIs. It does not follow HTTP semantics and it breaks in unexpected ways with proxies and gateways that strip unknown headers. Use Accept with a vendor media type if you go this route.
GET /users/42?version=2
The version is a query parameter. It is the easiest to implement (one middleware check) and the hardest to maintain.
What it costs you:
?version=2 may get a cached response meant for v2, or vice versa, depending on your cache configuration.What it buys you:
req.apiVersion. No routing changes, no header inspection.?version=2 to opt into the new behavior. Great for gradual rollouts of internal migration.When to use it: Never for public APIs. Use it only for internal, short-lived version transitions where you are migrating one service at a time and plan to remove the parameter after 90 days. It is a migration tool, not a versioning strategy.
Regardless of which strategy you pick, your version promise is only as good as your backward-compatibility rules. Here are the rules that survive production.
Adding a field is not breaking. An extra key in a JSON response should be ignored by any well-written client. If it is not, that is a client bug, not an API break. Never bump a version for adding a field.
Changing a field’s type is breaking. String to number, number to string, null to array. Always a version bump.
Removing a field is breaking. Unless you have telemetry proving zero clients depend on it. You rarely have that telemetry. Assume every field has consumers. Bump the version.
Changing the order of enum values is breaking. Clients that use numeric index instead of the string value exist. You will never know about them until they break. Freeze enum order per version or version the enum.
Adding a new endpoint is not breaking. Existing clients do not call it. The API surface expands. No version bump.
Changing status codes is breaking. A client that parses 201 Created may not handle 200 OK. A client that expects 404 Not found for missing resources may not handle 403 Forbidden. Status codes are part of the contract.
Changing error shapes is breaking. If your v1 errors look like { "error": "message" } and your “backward-compatible” v2 errors look like { "code": "NOT_FOUND", "detail": "message" }, existing clients that parse error will silently swallow errors. Version bump.
Write these rules into your OpenAPI spec. Enforce them with a diff check in CI:
# Pseudo-code: diff the spec, fail on breaking changes
npx openapi-diff --old spec-v1.yaml --new spec-v2.yaml --fail-on-breaking
The OpenAPI Specification Diff tool or OasDiff can automate this. Run it in CI on every PR that touches the spec.
The biggest pain of versioning is maintaining parallel handlers. The fix is not to eliminate duplication entirely (some is inevitable) but to confine it to a thin transform layer.
request
|
v
router ──→ version resolver ──→ business logic (shared)
|
v
response formatter (version-specific)
The business logic runs once. The version-specific part is only the response shape and any input parsing differences. If you find yourself copying an entire handler to change one field, you have not versioned your API; you have forked your codebase.
// Bad: forked handlers
async function getUserV1(id: string) {
const user = await db.findUser(id);
return { user_name: user.name, email_addr: user.email };
}
async function getUserV2(id: string) {
const user = await db.findUser(id);
return { username: user.name, email: user.email, role: user.role };
}
// Better: shared logic, versioned transform
async function getUser(id: string, version: 1 | 2) {
const user = await db.findUser(id);
const response = { name: user.name, email: user.email, role: user.role };
if (version === 1) {
return {
user_name: response.name,
email_addr: response.email,
};
}
return {
username: response.name,
email: response.email,
role: response.role,
};
}
This pattern keeps the database query and business rules in one place. The version switch is a translation layer at the boundary. When you eventually drop v1, you delete the if (version === 1) branch and rename the response keys. One file changes, not a whole handler.
Versioning without sunsetting is just accumulating cruft. Every major API I have worked on that is older than five years has at least three versions live, nobody knows which clients use the oldest one, and the team is afraid to remove it.
Follow this playbook instead.
Step 1: Track usage. Every response includes a version identifier. Log it. Aggregate by client ID. Know which clients call which version, and when they last called it.
// Response header
api-supported-versions: 1, 2, 3
api-deprecated-versions: 1
// Middleware to log usage
app.use((req, res, next) => {
res.on('finish', () => {
logger.info({
path: req.path,
version: req.apiVersion,
clientId: req.headers['x-client-id'] ?? 'unknown',
status: res.statusCode,
});
});
next();
});
Step 2: Announce deprecation. Set a sunset date at least six months out. Add a Sunset header to responses for the deprecated version. Add a Warning header with a descriptive message.
Sunset: Sat, 01 Dec 2026 23:59:59 GMT
Warning: 299 - "v1 is deprecated. Migrate to v2. See https://docs.example.com/migration-guide"
Step 3: Surface migration guides. For every breaking change between v1 and v2, write exactly one migration step. “Rename user_name to username” is one step. “Restructure your entire request flow” is multiple steps, which means you are doing too much in one version bump. Break it into v2 and v3.
Step 4: Freeze the deprecated version. No new features. Security patches only. Document that the version is in maintenance mode so teams do not accidentally build on top of it.
Step 5: Drop it when usage hits zero. Not when the sunset date passes. When usage hits zero. If you set a date and usage is not zero, you either extend the date or you are breaking clients that chose not to migrate. The sunset date is for you to plan the work, not for the clients to migrate by. They will migrate when they are ready.
I have never seen an API version get to zero usage on the scheduled date. Plan for a 12-18 month overlap window between releasing vN+1 and fully decommissioning vN.
Here is the full wiring for URL-based versioning in a Fastify app:
import Fastify from 'fastify';
const app = Fastify();
// Shared business logic
async function getUserData(id: string) {
return db.findUser(id);
}
// v1 handler
app.get('/v1/users/:id', async (req, reply) => {
const user = await getUserData(req.params.id);
return { user_name: user.name, email_addr: user.email };
});
// v2 handler
app.get('/v2/users/:id', async (req, reply) => {
const user = await getUserData(req.params.id);
return { username: user.name, email: user.email, role: user.role };
});
// Deprecation middleware
app.addHook('onSend', (req, reply, payload, done) => {
if (req.url.startsWith('/v1/')) {
reply.header('Sunset', 'Sat, 01 Dec 2026 23:59:59 GMT');
reply.header('Warning', '299 - "v1 is deprecated. Migrate to v2."');
reply.header('api-supported-versions', '1, 2');
reply.header('api-deprecated-versions', '1');
}
done();
});
For header-based versioning in Express:
import express from 'express';
const app = express();
app.get('/users/:id', (req, res) => {
const version = parseVersion(req.headers['accept']);
const user = getUserData(req.params.id); // shared
if (version === 1) {
return res.json({ user_name: user.name, email_addr: user.email });
}
if (version === 2) {
return res.json({ username: user.name, email: user.email, role: user.role });
}
});
function parseVersion(accept?: string): number {
if (!accept) return 1;
const match = accept.match(/application\/vnd\.example\.v(\d+)\+json/);
return match ? parseInt(match[1], 10) : 1;
}
Pick URL versioning for public APIs and header versioning for internal ones. Never use query-parameter versioning for anything that lives longer than one migration cycle. Write down what counts as a breaking change and enforce it with a spec diff in CI. Share business logic across versions, translate at the boundary. Track usage, set a sunset date six months out, freeze the deprecated version, and drop it when usage hits zero, not when the calendar says so.
The API you ship today will be maintained by somebody who has never met you. Give them a versioning story that does not require reading your mind.
Building APIs that serve multiple client versions simultaneously without accruing technical debt is the kind of architectural maturity that separates a platform from a prototype. Yojji’s teams design versioning strategies from day one, bake deprecation tracking into the response layer, and keep shared business logic from duplicating across versions so that when the old one finally sunsets, the cleanup is measured in hours, not months.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product engineering from discovery through DevOps.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。