API Versioning Strategy
Overview
Comprehensive guide to API versioning approaches, deprecation strategies, backward compatibility techniques, and migration planning for REST APIs, GraphQL, and gRPC services.
When to Use
- Designing new APIs with versioning from the start
- Adding breaking changes to existing APIs
- Deprecating old API versions
- Planning API migrations
- Ensuring backward compatibility
- Managing multiple API versions simultaneously
- Creating API documentation for different versions
- Implementing API version routing
Instructions
1. Versioning Approaches
URL Path Versioning
// express-router.ts
import express from "express";
const app = express();
// Version 1
app.get("/api/v1/users", (req, res) => {
res.json({
users: [{ id: 1, name: "John Doe" }],
});
});
// Version 2 - Added email field
app.get("/api/v2/users", (req, res) => {
res.json({
users: [{ id: 1, name: "John Doe", email: "john@example.com" }],
});
});
// Shared logic with version-specific transformations
app.get("/api/:version/users/:id", async (req, res) => {
const user = await userService.findById(req.params.id);
if (req.params.version === "v1") {
res.json({ id: user.id, name: user.name });
} else if (req.params.version === "v2") {
res.json({ id: user.id, name: user.name, email: user.email });
}
});
Pros: Simple, explicit, cache-friendly Cons: URL pollution, harder to deprecate
Header Versioning (Content Negotiation)
// header-versioning.ts
app.get("/api/users", (req, res) => {
const version = req.headers["api-version"] || "1";
switch (version) {
case "1":
return res.json(transformToV1(users));
case "2":
return res.json(transformToV2(users));
default:
return res.status(400).json({ error: "Unsupported API version" });
}
});
// Or using Accept header
app.get("/api/users", (req, res) => {
const acceptHeader = req.headers["accept"];
if (acceptHeader.includes("application/vnd.myapi.v2+json")) {
return res.json(transformToV2(users));
}
// Default to v1
return res.json(transformToV1(users));
});
Pros: Clean URLs, RESTful Cons: Less visible, harder to test manually
Query Parameter Versioning
// query-param-versioning.ts
app.get("/api/users", (req, res) => {
const version = req.query.version || "1";
if (version === "2") {
return res.json(transformToV2(users));
}
return res.json(transformToV1(users));
});
// Usage: GET /api/users?version=2
Pros: Easy to implement, flexible Cons: Not RESTful, can be overlooked
2. Backward Compatibility Patterns
Additive Changes (Non-Breaking)
// ✅ Safe: Adding optional fields
interface UserV1 {
id: string;
name: string;
}
interface UserV2 extends UserV1 {
email?: string; // Optional field
avatar?: string; // Optional field
}
// ✅ Safe: Adding new endpoints
app.post("/api/v1/users/:id/avatar", uploadAvatar);
// ✅ Safe: Accepting additional parameters
app.get("/api/v1/users", (req, res) => {
const { page, limit, sortBy } = req.query; // New optional params
const users = await userService.list({ page, limit, sortBy });
res.json(users);
});
Breaking Changes (Require New Version)
// ❌ Breaking: Removing fields
interface UserV1 {
id: string;
name: string;
username: string;
}
interface UserV2 {
id: string;
name: string;
// username removed - BREAKING!
}
// ❌ Breaking: Changing field types
interface UserV1 {
id: string;
created: string; // ISO string
}
interface UserV2 {
id: string;
created: number; // Unix timestamp - BREAKING!
}
// ❌ Breaking: Renaming fields
interface UserV1 {
fullName: string;
}
interface UserV2 {
name: string; // Renamed from fullName - BREAKING!
}
// ❌ Breaking: Changing response structure
// V1
{ users: [...], total: 10 }
// V2 - BREAKING!
{ data: [...], meta: { total: 10 } }
Handling Both Versions
// version-adapter.ts
export class UserAdapter {
toV1(user: User): UserV1Response {
return {
id: user.id,
name: user.fullName,
username: user.username,
created: user.createdAt.toISOString(),
};
}
toV2(user: User): UserV2Response {
return {
id: user.id,
name: user.fullName,
email: user.email,
profile: {
avatar: user.avatarUrl,
bio: user.bio,
},
createdAt: user.createdAt.getTime(),
};
}
fromV1(data: UserV1Request): User {
return {
fullName: data.name,
username: data.username,
email: data.email || null,
};
}
fromV2(data: UserV2Request): User {
return {
fullName: data.name,
username: data.username || generateUsername(data.email),
email: data.email,
avatarUrl: data.profile?.avatar,
bio: data.profile?.bio,
};
}
}
// Usage in controller
app.get("/api/:version/users/:id", async (req, res) => {
const user = await userService.findById(req.params.id);
const adapter = new UserAdapter();
const response =
req.params.version === "v2" ? adapter.toV2(user) : adapter.toV1(user);
res.json(response);
});
3. Deprecation Strategy
Deprecation Headers
// deprecation-middleware.ts
export function deprecationWarning(version: string, sunsetDate: Date) {
return (req, res, next) => {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", sunsetDate.toUTCString());
res.setHeader("Link", '</api/v2/docs>; rel="successor-version"');
res.setHeader(
"X-API-Warn",
`Version ${version} is deprecated. Please migrate to v2 by ${sunsetDate.toDateString()}`,
);
next();
};
}
// Apply to deprecated routes
app.use("/api/v1/*", deprecationWarning("v1", new Date("2024-12-31")));
app.get("/api/v1/users", (req, res) => {
// Return v1 response with deprecation headers
res.json(users);
});
Deprecation Response
// Include deprecation info in response body
app.get('/api/v1/users', (req, res) => {
res.json({
_meta: {
deprecated: true,
sunsetDate: '2024-12-31',
message: 'This API version is deprecated. Please migrate to v2.',
migrationGuide: 'https://docs.example.com/migration-v1-to-v2'
},
users: [...]
});
});
Gradual Deprecation Timeline
// deprecation-stages.ts
enum DeprecationStage {
SUPPORTED = "supported",
DEPRECATED = "deprecated",
SUNSET_ANNOUNCED = "sunset_announced",
READONLY = "readonly",
SHUTDOWN = "shutdown",
}
const versionStatus = {
v1: {
stage: DeprecationStage.READONLY,
sunsetDate: new Date("2024-06-30"),
message: "Read-only mode. New writes are disabled.",
},
v2: {
stage: DeprecationStage.DEPRECATED,
sunsetDate: new Date("2024-12-31"),
message: "Deprecated. Please migrate to v3.",
},
v3: {
stage: DeprecationStage.SUPPORTED,
message: "Current stable version.",
},
};
// Middleware to enforce deprecation
app.use("/api/:version/*", (req, res, next) => {
const status = versionStatus[req.params.version];
if (!status) {
return res.status(404).json({ error: "API version not found" });
}
if (status.stage === DeprecationStage.SHUTDOWN) {
return res.status(410).json({ error: "API version no longer available" });
}
if (
status.stage === DeprecationStage.READONLY &&
["POST", "PUT", "PATCH", "DELETE"].includes(req.method)
) {
return res.status(403).json({
error: "API version is read-only",
message: status.message,
});
}
// Add deprecation headers
if (status.stage !== DeprecationStage.SUPPORTED) {
res.setHeader("X-API-Deprecated", "true");
res.setHeader("X-API-Sunset", status.sunsetDate.toISOString());
}
next();
});
4. Migration Guide Example
# API Migration Guide: v1 to v2
## Overview
Version 2 introduces breaking changes to improve consistency and add new features.
**Timeline:**
- 2024-01-01: v2 released
- 2024-06-01: v1 deprecated
- 2024-09-01: v1 read-only
- 2024-12-31: v1 shutdown
## Breaking Changes
### 1. Response Structure
**v1:**
```json
{
"users": [...],
"total": 10,
"page": 1
}
```
v2:
{
"data": [...],
"meta": {
"total": 10,
"page": 1,
"perPage": 20
}
}
Migration:
// Before
const users = response.users;
const total = response.total;
// After
const users = response.data;
const total = response.meta.total;
2. Date Format
v1: ISO 8601 strings v2: Unix timestamps
Migration:
// Before
const created = new Date(user.created);
// After
const created = new Date(user.created * 1000);
3. Error Format
v1:
{ "error": "User not found" }
v2:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "User not found",
"details": {}
}
}
New Features in v2
Pagination
// v2 supports cursor-based pagination
GET /api/v2/users?cursor=eyJpZCI6MTIzfQ&limit=20
Field Selection
// v2 supports field filtering
GET /api/v2/users?fields=id,name,email
Batch Operations
// v2 supports batch requests
POST /api/v2/batch
{
"requests": [
{ "method": "GET", "path": "/users/1" },
{ "method": "GET", "path": "/users/2" }
]
}
Code Examples
JavaScript/TypeScript
// v1 Client
class ApiClientV1 {
async getUsers() {
const response = await fetch("/api/v1/users");
const data = await response.json();
return data.users;
}
}
// v2 Client
class ApiClientV2 {
async getUsers() {
const response = await fetch("/api/v2/users");
const data = await response.json();
return data.data; // Changed from .users to .data
}
}
Python
# v1
response = requests.get(f"{base_url}/api/v1/users")
users = response.json()["users"]
# v2
response = requests.get(f"{base_url}/api/v2/users")
users = response.json()["data"]
### 5. **GraphQL Versioning**
```typescript
// GraphQL handles versioning differently - through schema evolution
// schema-v1.graphql
type User {
id: ID!
name: String!
username: String!
}
// schema-v2.graphql (deprecated fields)
type User {
id: ID!
name: String!
username: String! @deprecated(reason: "Use email instead")
email: String!
profile: Profile
}
type Profile {
avatar: String
bio: String
}
// Field deprecation in resolver
const resolvers = {
User: {
username: (user) => {
console.warn('username field is deprecated, use email instead');
return user.email;
}
}
};
6. gRPC Versioning
// v1/user.proto
syntax = "proto3";
package user.v1;
message User {
string id = 1;
string name = 2;
}
// v2/user.proto
syntax = "proto3";
package user.v2;
message User {
string id = 1;
string name = 2;
string email = 3;
Profile profile = 4;
}
message Profile {
string avatar = 1;
string bio = 2;
}
// Both versions can coexist
service UserServiceV1 {
rpc GetUser (GetUserRequest) returns (user.v1.User);
}
service UserServiceV2 {
rpc GetUser (GetUserRequest) returns (user.v2.User);
}
7. Version Detection & Routing
// version-router.ts
import express from "express";
export class VersionRouter {
private versions = new Map<string, express.Router>();
registerVersion(version: string, router: express.Router) {
this.versions.set(version, router);
}
getMiddleware() {
return (req, res, next) => {
// Detect version from multiple sources
const version = this.detectVersion(req);
const router = this.versions.get(version);
if (!router) {
return res.status(400).json({
error: "Invalid API version",
supportedVersions: Array.from(this.versions.keys()),
});
}
// Set version in request for logging
req.apiVersion = version;
// Use versioned router
router(req, res, next);
};
}
private detectVersion(req): string {
// 1. Check URL path
const pathMatch = req.path.match(/^\/api\/v(\d+)\//);
if (pathMatch) return pathMatch[1];
// 2. Check header
if (req.headers["api-version"]) {
return req.headers["api-version"];
}
// 3. Check Accept header
const acceptMatch = req.headers["accept"]?.match(
/application\/vnd\.myapi\.v(\d+)\+json/,
);
if (acceptMatch) return acceptMatch[1];
// 4. Check query parameter
if (req.query.version) {
return req.query.version;
}
// 5. Default version
return "1";
}
}
// Usage
const versionRouter = new VersionRouter();
versionRouter.registerVersion("1", v1Router);
versionRouter.registerVersion("2", v2Router);
versionRouter.registerVersion("3", v3Router);
app.use("/api", versionRouter.getMiddleware());
8. Testing Multiple Versions
// api-version.test.ts
describe("API Versioning", () => {
describe("v1", () => {
it("should return user with v1 format", async () => {
const response = await request(app).get("/api/v1/users/1").expect(200);
expect(response.body).toHaveProperty("id");
expect(response.body).toHaveProperty("name");
expect(response.body).not.toHaveProperty("email");
});
});
describe("v2", () => {
it("should return user with v2 format", async () => {
const response = await request(app).get("/api/v2/users/1").expect(200);
expect(response.body).toHaveProperty("id");
expect(response.body).toHaveProperty("name");
expect(response.body).toHaveProperty("email");
expect(response.body).toHaveProperty("profile");
});
it("should include deprecation headers for v1", async () => {
const response = await request(app).get("/api/v1/users/1");
expect(response.headers["deprecation"]).toBe("true");
expect(response.headers["sunset"]).toBeDefined();
});
});
describe("version negotiation", () => {
it("should use version from header", async () => {
const response = await request(app)
.get("/api/users/1")
.set("API-Version", "2")
.expect(200);
expect(response.body).toHaveProperty("email");
});
it("should default to v1 if no version specified", async () => {
const response = await request(app).get("/api/users/1").expect(200);
expect(response.body).not.toHaveProperty("email");
});
});
});
Best Practices
✅ DO
- Version from day one (even if v1)
- Document breaking vs non-breaking changes
- Provide clear migration guides with code examples
- Use semantic versioning principles
- Give 6-12 months deprecation notice
- Monitor usage of deprecated APIs
- Send deprecation warnings to API consumers
- Support at least 2 versions simultaneously
- Use adapters/transformers for version logic
- Test all supported versions
- Log which API version is being used
- Provide migration tooling when possible
- Be consistent with versioning approach
❌ DON'T
- Change API behavior without versioning
- Remove versions without notice
- Support too many versions (>3)
- Use different versioning strategies in same API
- Break APIs without incrementing version
- Forget to update documentation
- Deprecate too quickly (<6 months)
- Ignore feedback from API consumers
- Make every change a new version
- Use version numbers inconsistently
Common Patterns
Pattern 1: Version-Agnostic Core
// Core logic remains version-agnostic
class UserService {
async getUser(id: string): Promise<User> {
return this.repository.findById(id);
}
}
// Version-specific adapters
class UserV1Adapter {
transform(user: User): UserV1 {
/* ... */
}
}
class UserV2Adapter {
transform(user: User): UserV2 {
/* ... */
}
}
Pattern 2: Feature Flags for Gradual Rollout
app.get("/api/v2/users", async (req, res) => {
const user = await userService.getUser(req.params.id);
// Gradual rollout of new feature
if (featureFlags.isEnabled("enhanced-profile", req.user.id)) {
return res.json(transformWithEnhancedProfile(user));
}
return res.json(transformV2(user));
});
Pattern 3: API Version Metrics
// Track usage by version
app.use((req, res, next) => {
const version = detectVersion(req);
metrics.increment("api.requests", { version });
next();
});
Tools & Resources
- OpenAPI/Swagger: API documentation with version support
- Postman: API testing with version management
- API Blueprint: API design with versioning
- Stoplight: API design and documentation
- Kong: API gateway with version routing