APIs evolve over time, and versioning strategies determine how you manage changes without breaking existing clients. This guide covers versioning approaches and best practices.
Why Version APIs?#
- Breaking changes: New requirements may require incompatible changes
- Multiple clients: Different clients may need different versions
- Gradual migration: Allow time for clients to update
- Clear contracts: Explicit version means clear expectations
Versioning Strategies#
URL Path Versioning#
Most common and explicit approach:
1// Express routes with URL versioning
2import { Router } from 'express';
3
4const v1Router = Router();
5const v2Router = Router();
6
7// V1 API
8v1Router.get('/users/:id', async (req, res) => {
9 const user = await db.users.findById(req.params.id);
10 res.json({
11 id: user.id,
12 name: user.name,
13 email: user.email,
14 });
15});
16
17// V2 API - different response structure
18v2Router.get('/users/:id', async (req, res) => {
19 const user = await db.users.findById(req.params.id);
20 res.json({
21 data: {
22 id: user.id,
23 attributes: {
24 name: user.name,
25 email: user.email,
26 createdAt: user.createdAt,
27 },
28 links: {
29 self: `/api/v2/users/${user.id}`,
30 },
31 },
32 });
33});
34
35app.use('/api/v1', v1Router);
36app.use('/api/v2', v2Router);Pros:
- Clear and explicit
- Easy to understand and implement
- Works with caching
Cons:
- Multiple code paths to maintain
- URL pollution
Header Versioning#
Version specified in request headers:
1// Version middleware using Accept header
2function versionMiddleware(req: Request, res: Response, next: NextFunction) {
3 const acceptHeader = req.headers.accept || '';
4
5 // Parse: application/vnd.myapi.v2+json
6 const match = acceptHeader.match(/application\/vnd\.myapi\.v(\d+)\+json/);
7
8 if (match) {
9 req.apiVersion = parseInt(match[1]);
10 } else {
11 req.apiVersion = 1; // Default version
12 }
13
14 next();
15}
16
17// Or custom header
18function customHeaderVersion(req: Request, res: Response, next: NextFunction) {
19 const version = req.headers['x-api-version'];
20 req.apiVersion = version ? parseInt(version as string) : 1;
21 next();
22}
23
24// Route handler using version
25app.get('/api/users/:id', versionMiddleware, async (req, res) => {
26 const user = await db.users.findById(req.params.id);
27
28 if (req.apiVersion === 2) {
29 return res.json({
30 data: { id: user.id, attributes: { name: user.name } },
31 });
32 }
33
34 // V1 response
35 res.json({ id: user.id, name: user.name });
36});Pros:
- Clean URLs
- Flexible
Cons:
- Less discoverable
- Harder to test in browser
Query Parameter Versioning#
1// Version via query parameter
2app.get('/api/users/:id', async (req, res) => {
3 const version = parseInt(req.query.version as string) || 1;
4 const user = await db.users.findById(req.params.id);
5
6 const transformers = {
7 1: (u: User) => ({ id: u.id, name: u.name }),
8 2: (u: User) => ({
9 data: { id: u.id, attributes: { name: u.name, email: u.email } },
10 }),
11 };
12
13 const transformer = transformers[version] || transformers[1];
14 res.json(transformer(user));
15});Pros:
- Easy to implement
- Easy to test
Cons:
- Can be cached incorrectly
- Pollutes query string
Implementing Version Transformers#
Response Transformation Layer#
1interface VersionTransformer<T, R> {
2 version: number;
3 transform: (data: T) => R;
4}
5
6class APIVersionManager<T> {
7 private transformers: Map<number, (data: T) => any> = new Map();
8
9 register(version: number, transformer: (data: T) => any) {
10 this.transformers.set(version, transformer);
11 }
12
13 transform(data: T, version: number): any {
14 const transformer = this.transformers.get(version);
15 if (!transformer) {
16 throw new Error(`Unsupported API version: ${version}`);
17 }
18 return transformer(data);
19 }
20}
21
22// Usage
23const userVersionManager = new APIVersionManager<User>();
24
25userVersionManager.register(1, (user) => ({
26 id: user.id,
27 name: user.name,
28}));
29
30userVersionManager.register(2, (user) => ({
31 data: {
32 type: 'user',
33 id: user.id,
34 attributes: {
35 name: user.name,
36 email: user.email,
37 createdAt: user.createdAt.toISOString(),
38 },
39 },
40}));
41
42app.get('/api/users/:id', async (req, res) => {
43 const user = await db.users.findById(req.params.id);
44 const version = req.apiVersion || 1;
45
46 res.json(userVersionManager.transform(user, version));
47});Request/Response Adapters#
1interface APIAdapter {
2 version: number;
3 parseRequest: (body: any) => any;
4 formatResponse: (data: any) => any;
5}
6
7const v1Adapter: APIAdapter = {
8 version: 1,
9 parseRequest: (body) => ({
10 name: body.name,
11 email: body.email,
12 }),
13 formatResponse: (user) => ({
14 id: user.id,
15 name: user.name,
16 }),
17};
18
19const v2Adapter: APIAdapter = {
20 version: 2,
21 parseRequest: (body) => ({
22 name: body.data?.attributes?.name,
23 email: body.data?.attributes?.email,
24 metadata: body.data?.meta,
25 }),
26 formatResponse: (user) => ({
27 data: {
28 type: 'user',
29 id: user.id,
30 attributes: {
31 name: user.name,
32 email: user.email,
33 },
34 },
35 links: {
36 self: `/api/v2/users/${user.id}`,
37 },
38 }),
39};
40
41function getAdapter(version: number): APIAdapter {
42 const adapters = { 1: v1Adapter, 2: v2Adapter };
43 return adapters[version] || v1Adapter;
44}
45
46app.post('/api/users', async (req, res) => {
47 const adapter = getAdapter(req.apiVersion);
48 const userData = adapter.parseRequest(req.body);
49
50 const user = await db.users.create(userData);
51
52 res.json(adapter.formatResponse(user));
53});Deprecation Strategy#
Deprecation Headers#
1function deprecationMiddleware(
2 deprecatedIn: string,
3 sunsetDate: string
4) {
5 return (req: Request, res: Response, next: NextFunction) => {
6 res.setHeader('Deprecation', `date="${deprecatedIn}"`);
7 res.setHeader('Sunset', sunsetDate);
8 res.setHeader(
9 'Link',
10 '</api/v2/users>; rel="successor-version"'
11 );
12 next();
13 };
14}
15
16// Apply to deprecated endpoints
17v1Router.use(deprecationMiddleware('2024-01-01', 'Sat, 01 Jul 2024 00:00:00 GMT'));Version Lifecycle#
1enum VersionStatus {
2 CURRENT = 'current',
3 DEPRECATED = 'deprecated',
4 SUNSET = 'sunset',
5}
6
7interface APIVersion {
8 version: number;
9 status: VersionStatus;
10 deprecatedAt?: Date;
11 sunsetAt?: Date;
12 successorVersion?: number;
13}
14
15const versions: APIVersion[] = [
16 {
17 version: 1,
18 status: VersionStatus.DEPRECATED,
19 deprecatedAt: new Date('2024-01-01'),
20 sunsetAt: new Date('2024-07-01'),
21 successorVersion: 2,
22 },
23 {
24 version: 2,
25 status: VersionStatus.CURRENT,
26 },
27];
28
29function versionStatusMiddleware(req: Request, res: Response, next: NextFunction) {
30 const version = versions.find(v => v.version === req.apiVersion);
31
32 if (!version) {
33 return res.status(400).json({ error: 'Unknown API version' });
34 }
35
36 if (version.status === VersionStatus.SUNSET) {
37 return res.status(410).json({
38 error: 'API version no longer available',
39 successorVersion: version.successorVersion,
40 });
41 }
42
43 if (version.status === VersionStatus.DEPRECATED) {
44 res.setHeader('X-API-Deprecated', 'true');
45 res.setHeader('X-API-Sunset-Date', version.sunsetAt!.toISOString());
46 }
47
48 next();
49}Backward Compatibility#
Additive Changes (Safe)#
// V1 response
{ "id": 1, "name": "John" }
// V1.1 response - added field (backward compatible)
{ "id": 1, "name": "John", "email": "john@example.com" }Breaking Changes (Require New Version)#
1// V1 response
2{ "id": 1, "name": "John" }
3
4// V2 response - restructured (breaking change)
5{
6 "data": {
7 "id": 1,
8 "attributes": { "name": "John" }
9 }
10}
11
12// V1 field removed - breaking
13// V1: { "fullName": "John Doe" }
14// V2: { "firstName": "John", "lastName": "Doe" }Compatibility Layer#
1// Maintain backward compatibility through transformation
2class CompatibilityLayer {
3 // Old field name -> new field name
4 private fieldMappings: Record<string, string> = {
5 fullName: 'name',
6 userName: 'username',
7 };
8
9 transformRequest(body: any, fromVersion: number): any {
10 if (fromVersion === 1) {
11 // Transform V1 request to current format
12 return this.migrateV1ToV2(body);
13 }
14 return body;
15 }
16
17 private migrateV1ToV2(body: any): any {
18 const transformed = { ...body };
19
20 for (const [oldKey, newKey] of Object.entries(this.fieldMappings)) {
21 if (oldKey in transformed) {
22 transformed[newKey] = transformed[oldKey];
23 delete transformed[oldKey];
24 }
25 }
26
27 return transformed;
28 }
29}Documentation#
OpenAPI with Multiple Versions#
1# openapi-v1.yaml
2openapi: 3.0.0
3info:
4 title: My API
5 version: '1.0'
6 x-deprecated: true
7 x-sunset-date: '2024-07-01'
8
9# openapi-v2.yaml
10openapi: 3.0.0
11info:
12 title: My API
13 version: '2.0'
14
15paths:
16 /users/{id}:
17 get:
18 summary: Get user by ID
19 parameters:
20 - name: id
21 in: path
22 required: true
23 schema:
24 type: string
25 responses:
26 '200':
27 description: User found
28 content:
29 application/json:
30 schema:
31 $ref: '#/components/schemas/UserResponse'Best Practices#
- Version from the start: Plan for versioning even in v1
- Use semantic versioning: Major for breaking, minor for features
- Document changes: Maintain a changelog per version
- Set deprecation timeline: Give clients time to migrate
- Monitor version usage: Track which versions are in use
- Test all versions: Automated tests for each supported version
- Communicate clearly: Notify clients of deprecation plans
Conclusion#
Choose a versioning strategy that fits your use case and stick with it. URL versioning is the most explicit and widely used. Focus on maintaining backward compatibility where possible, and have a clear deprecation policy for managing version lifecycle.