The Hidden Complexity of API Versioning

API versioning is more complex than you think. Learn how to implement it properly and avoid common pitfalls.

API versioning seems straightforward until you actually need to implement it. Here's what they don't tell you about versioning APIs, and how to handle it properly.

Common Anti-Patterns

1. URL Versioning

// ❌ BAD: URL-based versioning
@Controller('api/v1/users')
class UserControllerV1 {
  @Get(':id')
  async getUser(id: string) {
    return this.userService.findById(id);
  }
}

@Controller('api/v2/users')
class UserControllerV2 {
  @Get(':id')
  async getUser(id: string) {
    // Duplicate logic with slight changes
    const user = await this.userService.findById(id);
    return this.userTransformer.toV2(user);
  }
}

2. Code Duplication

// ❌ BAD: Version-specific services
class UserServiceV1 {
  async createUser(data: CreateUserDTOV1) {
    // Old logic
  }
}

class UserServiceV2 {
  async createUser(data: CreateUserDTOV2) {
    // Almost identical logic with minor changes
  }
}

Better Approaches

1. Content Negotiation

// ✅ BETTER: Accept header versioning
@Controller('users')
class UserController {
  @Get(':id')
  @Header('Content-Type', 'application/json')
  async getUser(
    @Param('id') id: string,
    @Headers('Accept-Version') version: string
  ) {
    const user = await this.userService.findById(id);
    
    return this.transformResponse(user, version);
  }

  private transformResponse(data: any, version: string) {
    switch (version) {
      case '2.0':
        return this.transformerV2.transform(data);
      case '1.0':
      default:
        return this.transformerV1.transform(data);
    }
  }
}

2. Schema Evolution

// ✅ BETTER: Evolving data models
interface UserBaseSchema {
  id: string;
  email: string;
  createdAt: string;
}

interface UserV1Schema extends UserBaseSchema {
  name: string;  // Combined name field
}

interface UserV2Schema extends UserBaseSchema {
  firstName: string;  // Split name fields
  lastName: string;
  version: 2;
}

class UserTransformer {
  toLatest(user: UserV1Schema): UserV2Schema {
    const [firstName, ...lastNames] = user.name.split(' ');
    
    return {
      ...user,
      firstName,
      lastName: lastNames.join(' '),
      version: 2
    };
  }

  toV1(user: UserV2Schema): UserV1Schema {
    return {
      ...user,
      name: `${user.firstName} ${user.lastName}`.trim()
    };
  }
}

3. Feature Flags

// ✅ BETTER: Feature-based versioning
class UserService {
  async getUser(
    id: string,
    features: FeatureSet
  ): Promise<User> {
    const user = await this.findById(id);
    
    if (features.enabled('extended_profile')) {
      await this.loadExtendedProfile(user);
    }
    
    if (features.enabled('social_graph')) {
      await this.loadSocialConnections(user);
    }
    
    return user;
  }
}

interface FeatureSet {
  enabled(feature: string): boolean;
  version: string;
}

class ApiFeatures implements FeatureSet {
  constructor(private apiVersion: string) {
    this.features = this.getFeaturesForVersion(apiVersion);
  }

  enabled(feature: string): boolean {
    return this.features.includes(feature);
  }

  private getFeaturesForVersion(version: string): string[] {
    switch (version) {
      case '2.0':
        return ['extended_profile', 'social_graph'];
      case '1.0':
      default:
        return [];
    }
  }
}

Advanced Patterns

1. Version Negotiation

// ✅ BETTER: Smart version negotiation
class VersionNegotiator {
  negotiate(
    requested: string,
    available: string[]
  ): string {
    // Sort versions semantically
    const sorted = available.sort(semver.rcompare);
    
    if (!requested) {
      return sorted[0]; // Latest version
    }
    
    // Find highest compatible version
    const compatible = sorted.find(version =>
      semver.satisfies(version, requested)
    );
    
    if (!compatible) {
      throw new UnsupportedVersionError(requested);
    }
    
    return compatible;
  }
}

@Controller()
class ApiController {
  @Use()
  async negotiateVersion(
    @Headers('Accept-Version') version: string
  ) {
    const negotiated = this.negotiator.negotiate(
      version,
      this.supportedVersions
    );
    
    req.negotiatedVersion = negotiated;
  }
}

2. Database Migrations

// ✅ BETTER: Version-aware data access
class UserRepository {
  async findById(
    id: string,
    version: string
  ): Promise<User> {
    const user = await this.db.users.findById(id);
    
    if (!user) {
      throw new NotFoundError('User not found');
    }
    
    // Apply necessary migrations
    return this.migrateToVersion(user, version);
  }

  private async migrateToVersion(
    user: UserDocument,
    targetVersion: string
  ): Promise<User> {
    const migrations = this.getMigrationPath(
      user.schemaVersion,
      targetVersion
    );
    
    let result = user;
    for (const migration of migrations) {
      result = await migration.up(result);
    }
    
    return result;
  }
}

class UserMigration_V1_to_V2 implements Migration {
  async up(user: UserV1): Promise<UserV2> {
    const [firstName, ...lastNames] = user.name.split(' ');
    
    return {
      ...user,
      firstName,
      lastName: lastNames.join(' '),
      schemaVersion: 2
    };
  }

  async down(user: UserV2): Promise<UserV1> {
    return {
      ...user,
      name: `${user.firstName} ${user.lastName}`,
      schemaVersion: 1
    };
  }
}

3. Documentation Generation

// ✅ BETTER: Version-aware API docs
class OpenApiGenerator {
  generate(version: string): OpenAPIDocument {
    return {
      openapi: '3.0.0',
      info: {
        version,
        title: 'API Documentation'
      },
      paths: this.getPathsForVersion(version),
      components: {
        schemas: this.getSchemasForVersion(version)
      }
    };
  }

  private getPathsForVersion(version: string) {
    const paths = { ...this.basePaths };
    
    if (semver.gte(version, '2.0.0')) {
      paths['/users/{id}/social'] = this.socialEndpoints;
    }
    
    return paths;
  }

  private getSchemasForVersion(version: string) {
    if (semver.gte(version, '2.0.0')) {
      return {
        User: UserSchemaV2,
        // ...other v2 schemas
      };
    }
    return {
      User: UserSchemaV1,
      // ...other v1 schemas
    };
  }
}

Best Practices

1. Version Compatibility

// ✅ BETTER: Explicit compatibility checks
class CompatibilityChecker {
  checkRequest(
    endpoint: string,
    version: string,
    payload: any
  ): void {
    const schema = this.getSchemaForVersion(
      endpoint,
      version
    );
    
    const validation = schema.validate(payload);
    if (!validation.success) {
      throw new ValidationError(
        'Incompatible request format',
        validation.errors
      );
    }
  }

  private getBreakingChanges(
    fromVersion: string,
    toVersion: string
  ): BreakingChange[] {
    return this.breakingChanges.filter(change =>
      semver.gt(change.version, fromVersion) &&
      semver.lte(change.version, toVersion)
    );
  }
}

Conclusion

For better API versioning:

  • Use content negotiation over URL versioning
  • Implement proper schema evolution
  • Use feature flags for gradual rollouts
  • Maintain backward compatibility
  • Document breaking changes clearly

Remember: API versioning is about managing change, not creating parallel implementations.