Skip to content
Home / Skills / Api Design / Versioning
AP

Versioning

Api Design core v1.0.0

API Versioning

Overview

API versioning enables evolving APIs while maintaining backward compatibility. This skill covers versioning strategies, breaking vs non-breaking changes, deprecation, and migration patterns.


Key Concepts

Versioning Strategies

┌─────────────────────────────────────────────────────────────┐
│                  API Versioning Strategies                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. URL Path Versioning:                                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  /api/v1/users                                      │   │
│  │  /api/v2/users                                      │   │
│  │                                                      │   │
│  │  ✓ Clear and explicit                               │   │
│  │  ✓ Easy to route                                    │   │
│  │  ✗ URI changes between versions                     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  2. Query Parameter Versioning:                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  /api/users?version=1                               │   │
│  │  /api/users?version=2                               │   │
│  │                                                      │   │
│  │  ✓ Same URI                                         │   │
│  │  ✓ Optional versioning                              │   │
│  │  ✗ Easy to miss in requests                         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  3. Header Versioning:                                      │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  X-API-Version: 1                                   │   │
│  │  Accept-Version: 2                                  │   │
│  │                                                      │   │
│  │  ✓ Clean URIs                                       │   │
│  │  ✓ Follows HTTP conventions                         │   │
│  │  ✗ Harder to test in browser                        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  4. Content Negotiation (Media Type):                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Accept: application/vnd.company.users.v1+json      │   │
│  │  Accept: application/vnd.company.users.v2+json      │   │
│  │                                                      │   │
│  │  ✓ Most RESTful approach                            │   │
│  │  ✓ Version per resource possible                    │   │
│  │  ✗ More complex to implement                        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  Breaking vs Non-Breaking Changes:                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  BREAKING (requires new version):                   │   │
│  │  • Removing fields                                   │   │
│  │  • Changing field types                              │   │
│  │  • Renaming fields                                   │   │
│  │  • Changing required/optional                       │   │
│  │  • Changing behavior                                 │   │
│  │                                                      │   │
│  │  NON-BREAKING (same version):                       │   │
│  │  • Adding new optional fields                       │   │
│  │  • Adding new endpoints                             │   │
│  │  • Adding new enum values                           │   │
│  │  • Relaxing validation rules                        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Best Practices

1. Version from Day One

Start with v1 even before you need v2.

2. Minimize Breaking Changes

Prefer additive changes.

3. Maintain Multiple Versions Temporarily

Support N-1 or N-2 versions.

4. Deprecate Before Removing

Give clients migration time.

5. Document Changes Clearly

Maintain changelog for each version.


Code Examples

Example 1: URL Path Versioning

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserV1Response> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserV1Response.from(user));
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserV2Response> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserV2Response.from(user));
    }
}

// V1 Response - original format
@Data
class UserV1Response {
    private Long id;
    private String name;           // Full name as single field
    private String email;
    
    public static UserV1Response from(User user) {
        UserV1Response response = new UserV1Response();
        response.id = user.getId();
        response.name = user.getFirstName() + " " + user.getLastName();
        response.email = user.getEmail();
        return response;
    }
}

// V2 Response - improved format
@Data
class UserV2Response {
    private Long id;
    private String firstName;      // Split name into components
    private String lastName;
    private String email;
    private String phoneNumber;    // New field
    private AddressResponse address;  // New nested object
    
    public static UserV2Response from(User user) {
        UserV2Response response = new UserV2Response();
        response.id = user.getId();
        response.firstName = user.getFirstName();
        response.lastName = user.getLastName();
        response.email = user.getEmail();
        response.phoneNumber = user.getPhoneNumber();
        response.address = AddressResponse.from(user.getAddress());
        return response;
    }
}

Example 2: Header-Based Versioning

@RestController
@RequestMapping("/api/users")
public class VersionedUserController {
    
    private final Map<String, UserResponseMapper> mappers = Map.of(
        "1", UserV1Response::from,
        "2", UserV2Response::from
    );
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestHeader(value = "X-API-Version", defaultValue = "2") String version) {
        
        UserResponseMapper mapper = mappers.get(version);
        if (mapper == null) {
            return ResponseEntity.badRequest()
                .body(new ErrorResponse("Unsupported API version: " + version));
        }
        
        User user = userService.findById(id);
        return ResponseEntity.ok(mapper.map(user));
    }
}

// Version resolver component
@Component
public class ApiVersionResolver implements HandlerMethodArgumentResolver {
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ApiVersion.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter, 
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, 
            WebDataBinderFactory binderFactory) {
        
        String version = webRequest.getHeader("X-API-Version");
        
        if (version == null) {
            version = webRequest.getParameter("version");
        }
        
        if (version == null) {
            ApiVersion annotation = parameter.getParameterAnnotation(ApiVersion.class);
            version = annotation.defaultValue();
        }
        
        return Integer.parseInt(version);
    }
}

// Usage with annotation
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
        @PathVariable Long id,
        @ApiVersion(defaultValue = "2") int version) {
    // Use version to select appropriate response format
}

Example 3: Content Negotiation Versioning

@RestController
@RequestMapping("/api/users")
public class MediaTypeVersionedController {
    
    private static final String V1_MEDIA_TYPE = "application/vnd.company.users.v1+json";
    private static final String V2_MEDIA_TYPE = "application/vnd.company.users.v2+json";
    
    @GetMapping(value = "/{id}", produces = V1_MEDIA_TYPE)
    public ResponseEntity<UserV1Response> getUserV1(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(V1_MEDIA_TYPE))
            .body(UserV1Response.from(user));
    }
    
    @GetMapping(value = "/{id}", produces = V2_MEDIA_TYPE)
    public ResponseEntity<UserV2Response> getUserV2(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(V2_MEDIA_TYPE))
            .body(UserV2Response.from(user));
    }
    
    // Default to latest version for standard JSON accept header
    @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserV2Response> getUserDefault(@PathVariable Long id) {
        return getUserV2(id);
    }
}

// Custom media type configuration
@Configuration
public class MediaTypeConfig implements WebMvcConfigurer {
    
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("v1", MediaType.parseMediaType("application/vnd.company.users.v1+json"))
            .mediaType("v2", MediaType.parseMediaType("application/vnd.company.users.v2+json"));
    }
}

Example 4: Deprecation Strategy

@RestController
@RequestMapping("/api/v1/orders")
@Deprecated(since = "2024-01-01", forRemoval = true)
public class OrderControllerV1 {
    
    @GetMapping("/{id}")
    public ResponseEntity<OrderV1Response> getOrder(@PathVariable String id) {
        // Add deprecation warning header
        return ResponseEntity.ok()
            .header("Deprecation", "true")
            .header("Sunset", "Sat, 01 Jul 2025 00:00:00 GMT")
            .header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"")
            .body(orderService.findByIdV1(id));
    }
}

// Deprecation filter for all v1 endpoints
@Component
public class DeprecationFilter extends OncePerRequestFilter {
    
    private final Map<String, DeprecationInfo> deprecatedPaths = Map.of(
        "/api/v1/", new DeprecationInfo(
            LocalDate.of(2025, 7, 1),
            "API v1 is deprecated. Please migrate to v2."
        )
    );
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, 
            FilterChain filterChain) throws ServletException, IOException {
        
        String path = request.getRequestURI();
        
        deprecatedPaths.entrySet().stream()
            .filter(e -> path.startsWith(e.getKey()))
            .findFirst()
            .ifPresent(e -> {
                DeprecationInfo info = e.getValue();
                response.setHeader("Deprecation", "true");
                response.setHeader("Sunset", info.sunsetDate.format(DateTimeFormatter.RFC_1123_DATE_TIME));
                response.setHeader("X-Deprecation-Notice", info.message);
                
                // Log deprecation usage for monitoring
                log.warn("Deprecated API accessed: {} by client {}", 
                    path, request.getHeader("X-Client-ID"));
            });
        
        filterChain.doFilter(request, response);
    }
}

// Migration guide response
@RestController
@RequestMapping("/api/migration")
public class MigrationController {
    
    @GetMapping("/v1-to-v2")
    public ResponseEntity<MigrationGuide> getMigrationGuide() {
        return ResponseEntity.ok(new MigrationGuide(
            "v1", "v2",
            List.of(
                new ChangeDescription(
                    "User.name split into firstName and lastName",
                    "GET /users/{id}",
                    "name", "firstName, lastName"
                ),
                new ChangeDescription(
                    "Added address field to user response",
                    "GET /users/{id}",
                    null, "address"
                )
            ),
            LocalDate.of(2025, 7, 1)
        ));
    }
}

Example 5: Version Transformation Layer

@Service
public class VersionTransformationService {
    
    private final Map<VersionPair, BiFunction<Object, Class<?>, Object>> transformers = new HashMap<>();
    
    @PostConstruct
    public void registerTransformers() {
        // Register transformers between versions
        transformers.put(
            new VersionPair(1, 2, UserResponse.class),
            (source, targetClass) -> transformUserV1ToV2((UserV1Response) source)
        );
        
        transformers.put(
            new VersionPair(2, 1, UserResponse.class),
            (source, targetClass) -> transformUserV2ToV1((UserV2Response) source)
        );
    }
    
    @SuppressWarnings("unchecked")
    public <T> T transform(Object source, int sourceVersion, int targetVersion, Class<T> targetClass) {
        if (sourceVersion == targetVersion) {
            return (T) source;
        }
        
        VersionPair key = new VersionPair(sourceVersion, targetVersion, source.getClass());
        BiFunction<Object, Class<?>, Object> transformer = transformers.get(key);
        
        if (transformer == null) {
            throw new UnsupportedVersionTransformationException(
                "No transformer for " + sourceVersion + " to " + targetVersion
            );
        }
        
        return (T) transformer.apply(source, targetClass);
    }
    
    private UserV2Response transformUserV1ToV2(UserV1Response v1) {
        UserV2Response v2 = new UserV2Response();
        
        // Split name into first/last
        String[] nameParts = v1.getName().split(" ", 2);
        v2.setFirstName(nameParts[0]);
        v2.setLastName(nameParts.length > 1 ? nameParts[1] : "");
        
        v2.setId(v1.getId());
        v2.setEmail(v1.getEmail());
        
        // New fields default to null
        v2.setPhoneNumber(null);
        v2.setAddress(null);
        
        return v2;
    }
    
    private UserV1Response transformUserV2ToV1(UserV2Response v2) {
        UserV1Response v1 = new UserV1Response();
        
        // Combine first/last into name
        v1.setName(v2.getFirstName() + " " + v2.getLastName());
        v1.setId(v2.getId());
        v1.setEmail(v2.getEmail());
        
        // V2-only fields are lost
        
        return v1;
    }
    
    record VersionPair(int sourceVersion, int targetVersion, Class<?> resourceType) {}
}

// Unified controller using transformation
@RestController
@RequestMapping("/api/users")
public class UnifiedUserController {
    
    private final UserService userService;
    private final VersionTransformationService transformer;
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestHeader(value = "X-API-Version", defaultValue = "2") int requestedVersion) {
        
        // Always fetch latest internal representation
        User user = userService.findById(id);
        
        // Transform to latest response version
        UserV2Response latestResponse = UserV2Response.from(user);
        
        // Transform to requested version if different
        Object response = transformer.transform(latestResponse, 2, requestedVersion, Object.class);
        
        return ResponseEntity.ok(response);
    }
}

Anti-Patterns

❌ No Versioning Strategy

Adding version after breaking clients is too late.

❌ Too Many Active Versions

Maintaining more than 2-3 versions is expensive.

// WRONG - no deprecation timeline
// v1, v2, v3, v4 all active indefinitely

// ✅ CORRECT - clear lifecycle
// v1: deprecated, sunset in 6 months
// v2: current stable
// v3: beta preview

References