Skip to content

5.1. API Versioning

Why It's Necessary

  • Ensuring sustainability of backend changes without affecting clients
  • Backward compatibility
  • Gradual migration
  • API lifecycle management

Implementation Methods

URL Versioning

  • /api/v1/resource
  • /api/v2/resource
  • Advantage: Simple and clear
  • Disadvantage: Changes URL structure

HTTP Headers

  • Accept: application/vnd.myapi.v2+json
  • Content-Type: application/vnd.myapi.v2+json
  • Advantage: Keeps URL clean
  • Disadvantage: More complex implementation

Query Parameters

  • ?version=2
  • ?api-version=2023-01-01
  • Advantage: Easy to test
  • Disadvantage: Caching difficulties

Best Practices

  • Semantic versioning (MAJOR.MINOR.PATCH)
  • Deprecation policy
  • Version lifecycle management
  • Documentation versioning
  • Client migration strategy

Spring Boot API Versioning Implementation

URL-Based Versioning

java
@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<UserV1Response> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        UserV1Response response = convertToV1Response(user);
        return ResponseEntity.ok(response);
    }
    
    @PostMapping
    public ResponseEntity<UserV1Response> createUser(@RequestBody @Valid UserV1Request request) {
        User user = userService.createUser(convertFromV1Request(request));
        UserV1Response response = convertToV1Response(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    private UserV1Response convertToV1Response(User user) {
        return UserV1Response.builder()
            .id(user.getId())
            .name(user.getFirstName() + " " + user.getLastName()) // V1 single name field
            .email(user.getEmail())
            .createdAt(user.getCreatedAt())
            .build();
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<UserV2Response> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        UserV2Response response = convertToV2Response(user);
        return ResponseEntity.ok(response);
    }
    
    @PostMapping
    public ResponseEntity<UserV2Response> createUser(@RequestBody @Valid UserV2Request request) {
        User user = userService.createUser(convertFromV2Request(request));
        UserV2Response response = convertToV2Response(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    private UserV2Response convertToV2Response(User user) {
        return UserV2Response.builder()
            .id(user.getId())
            .firstName(user.getFirstName()) // V2 separate name fields
            .lastName(user.getLastName())
            .email(user.getEmail())
            .phone(user.getPhone()) // V2 new field
            .profile(user.getProfile()) // V2 new object
            .createdAt(user.getCreatedAt())
            .updatedAt(user.getUpdatedAt())
            .build();
    }
}

Header-Based Versioning

java
@RestController
@RequestMapping("/api/users")
public class VersionedUserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestHeader(value = "Accept", required = false) String acceptHeader,
            @RequestHeader(value = "API-Version", required = false) String apiVersion) {
        
        String version = determineVersion(acceptHeader, apiVersion);
        User user = userService.findById(id);
        
        switch (version) {
            case "v1":
                return ResponseEntity.ok(convertToV1Response(user));
            case "v2":
                return ResponseEntity.ok(convertToV2Response(user));
            default:
                return ResponseEntity.ok(convertToV2Response(user)); // Default latest
        }
    }
    
    @PostMapping
    public ResponseEntity<?> createUser(
            @RequestBody Map<String, Object> requestBody,
            @RequestHeader(value = "Content-Type", required = false) String contentType,
            @RequestHeader(value = "API-Version", required = false) String apiVersion) {
        
        String version = determineVersionFromContentType(contentType, apiVersion);
        
        try {
            User user;
            switch (version) {
                case "v1":
                    UserV1Request v1Request = objectMapper.convertValue(requestBody, UserV1Request.class);
                    user = userService.createUser(convertFromV1Request(v1Request));
                    return ResponseEntity.status(HttpStatus.CREATED).body(convertToV1Response(user));
                case "v2":
                    UserV2Request v2Request = objectMapper.convertValue(requestBody, UserV2Request.class);
                    user = userService.createUser(convertFromV2Request(v2Request));
                    return ResponseEntity.status(HttpStatus.CREATED).body(convertToV2Response(user));
                default:
                    return ResponseEntity.badRequest().body("Unsupported API version");
            }
        } catch (Exception e) {
            return ResponseEntity.badRequest().body("Invalid request format for version: " + version);
        }
    }
    
    private String determineVersion(String acceptHeader, String apiVersion) {
        if (apiVersion != null) {
            return apiVersion;
        }
        
        if (acceptHeader != null) {
            if (acceptHeader.contains("application/vnd.myapi.v1+json")) {
                return "v1";
            } else if (acceptHeader.contains("application/vnd.myapi.v2+json")) {
                return "v2";
            }
        }
        
        return "v2"; // Default to latest
    }
    
    private String determineVersionFromContentType(String contentType, String apiVersion) {
        if (apiVersion != null) {
            return apiVersion;
        }
        
        if (contentType != null) {
            if (contentType.contains("application/vnd.myapi.v1+json")) {
                return "v1";
            } else if (contentType.contains("application/vnd.myapi.v2+json")) {
                return "v2";
            }
        }
        
        return "v2"; // Default to latest
    }
}

Query Parameter Versioning

java
@RestController
@RequestMapping("/api/users")
public class QueryVersionedUserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestParam(value = "version", defaultValue = "2") String version,
            @RequestParam(value = "api-version", required = false) String apiVersion) {
        
        String finalVersion = apiVersion != null ? apiVersion : version;
        User user = userService.findById(id);
        
        switch (finalVersion) {
            case "1":
            case "v1":
                return ResponseEntity.ok(convertToV1Response(user));
            case "2":
            case "v2":
                return ResponseEntity.ok(convertToV2Response(user));
            default:
                return ResponseEntity.badRequest().body("Unsupported version: " + finalVersion);
        }
    }
    
    @GetMapping
    public ResponseEntity<?> getUsers(
            @RequestParam(value = "version", defaultValue = "2") String version,
            @RequestParam(value = "page", defaultValue = "0") int page,
            @RequestParam(value = "size", defaultValue = "20") int size) {
        
        Pageable pageable = PageRequest.of(page, size);
        Page<User> users = userService.findAll(pageable);
        
        switch (version) {
            case "1":
            case "v1":
                List<UserV1Response> v1Responses = users.getContent().stream()
                    .map(this::convertToV1Response)
                    .collect(Collectors.toList());
                return ResponseEntity.ok(new PagedResponse<>(v1Responses, users));
            case "2":
            case "v2":
                List<UserV2Response> v2Responses = users.getContent().stream()
                    .map(this::convertToV2Response)
                    .collect(Collectors.toList());
                return ResponseEntity.ok(new PagedResponse<>(v2Responses, users));
            default:
                return ResponseEntity.badRequest().body("Unsupported version: " + version);
        }
    }
}

Custom Annotation-Based Versioning

java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    String value();
}

@Component
public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
    
    private static final String VERSION_HEADER = "API-Version";
    private static final String VERSION_PARAM = "version";
    
    @Override
    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return createCondition(apiVersion);
    }
    
    @Override
    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class);
        return createCondition(apiVersion);
    }
    
    private RequestCondition<?> createCondition(ApiVersion apiVersion) {
        return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
    }
    
    private static class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
        private final String version;
        
        public ApiVersionCondition(String version) {
            this.version = version;
        }
        
        @Override
        public ApiVersionCondition combine(ApiVersionCondition other) {
            return new ApiVersionCondition(other.version);
        }
        
        @Override
        public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
            String requestVersion = getVersionFromRequest(request);
            return version.equals(requestVersion) ? this : null;
        }
        
        @Override
        public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
            return other.version.compareTo(this.version);
        }
        
        private String getVersionFromRequest(HttpServletRequest request) {
            String headerVersion = request.getHeader(VERSION_HEADER);
            if (headerVersion != null) {
                return headerVersion;
            }
            
            String paramVersion = request.getParameter(VERSION_PARAM);
            if (paramVersion != null) {
                return paramVersion;
            }
            
            return "v2"; // Default version
        }
    }
}

@RestController
@RequestMapping("/api/users")
public class AnnotationVersionedUserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    @ApiVersion("v1")
    public ResponseEntity<UserV1Response> getUserV1(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(convertToV1Response(user));
    }
    
    @GetMapping("/{id}")
    @ApiVersion("v2")
    public ResponseEntity<UserV2Response> getUserV2(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(convertToV2Response(user));
    }
    
    @PostMapping
    @ApiVersion("v1")
    public ResponseEntity<UserV1Response> createUserV1(@RequestBody @Valid UserV1Request request) {
        User user = userService.createUser(convertFromV1Request(request));
        return ResponseEntity.status(HttpStatus.CREATED).body(convertToV1Response(user));
    }
    
    @PostMapping
    @ApiVersion("v2")
    public ResponseEntity<UserV2Response> createUserV2(@RequestBody @Valid UserV2Request request) {
        User user = userService.createUser(convertFromV2Request(request));
        return ResponseEntity.status(HttpStatus.CREATED).body(convertToV2Response(user));
    }
}

API Version Management Service

java
@Service
@Slf4j
public class ApiVersionManagementService {
    
    @Value("${api.version.current:v2}")
    private String currentVersion;
    
    @Value("${api.version.supported:v1,v2}")
    private List<String> supportedVersions;
    
    @Value("${api.version.deprecated:}")
    private List<String> deprecatedVersions;
    
    private final Map<String, Instant> versionDeprecationDates = new HashMap<>();
    private final Map<String, Instant> versionSunsetDates = new HashMap<>();
    
    @PostConstruct
    public void initializeVersionLifecycle() {
        // V1 deprecation and sunset dates
        versionDeprecationDates.put("v1", Instant.parse("2024-06-01T00:00:00Z"));
        versionSunsetDates.put("v1", Instant.parse("2024-12-01T00:00:00Z"));
        
        log.info("API Version Management initialized. Current: {}, Supported: {}", 
            currentVersion, supportedVersions);
    }
    
    public boolean isVersionSupported(String version) {
        return supportedVersions.contains(version);
    }
    
    public boolean isVersionDeprecated(String version) {
        return deprecatedVersions.contains(version);
    }
    
    public boolean isVersionSunset(String version) {
        Instant sunsetDate = versionSunsetDates.get(version);
        return sunsetDate != null && Instant.now().isAfter(sunsetDate);
    }
    
    public String getCurrentVersion() {
        return currentVersion;
    }
    
    public List<String> getSupportedVersions() {
        return new ArrayList<>(supportedVersions);
    }
    
    public void addDeprecationHeaders(HttpServletResponse response, String version) {
        if (isVersionDeprecated(version)) {
            Instant sunsetDate = versionSunsetDates.get(version);
            response.addHeader("Deprecation", "true");
            response.addHeader("Warning", "299 - \"API version " + version + " is deprecated\"");
            
            if (sunsetDate != null) {
                response.addHeader("Sunset", sunsetDate.toString());
            }
        }
    }
    
    @EventListener
    @Async
    public void handleVersionUsage(ApiVersionUsageEvent event) {
        // Track version usage for analytics
        log.info("API Version {} used by client: {}", 
            event.getVersion(), event.getClientId());
            
        // Send notifications for deprecated version usage
        if (isVersionDeprecated(event.getVersion())) {
            notifyClientOfDeprecation(event.getClientId(), event.getVersion());
        }
    }
    
    private void notifyClientOfDeprecation(String clientId, String version) {
        // Implementation for notifying clients about deprecated versions
        log.warn("Client {} is using deprecated API version {}", clientId, version);
    }
}

@Component
public class ApiVersionInterceptor implements HandlerInterceptor {
    
    @Autowired
    private ApiVersionManagementService versionManagementService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                           Object handler) throws Exception {
        
        String version = extractVersionFromRequest(request);
        
        // Check if version is supported
        if (!versionManagementService.isVersionSupported(version)) {
            response.setStatus(HttpStatus.BAD_REQUEST.value());
            response.getWriter().write("{\"error\":\"Unsupported API version: " + version + "\"}");
            return false;
        }
        
        // Check if version is sunset
        if (versionManagementService.isVersionSunset(version)) {
            response.setStatus(HttpStatus.GONE.value());
            response.getWriter().write("{\"error\":\"API version " + version + " is no longer available\"}");
            return false;
        }
        
        // Add deprecation headers if needed
        versionManagementService.addDeprecationHeaders(response, version);
        
        // Track version usage
        String clientId = request.getHeader("X-Client-ID");
        if (clientId != null) {
            ApiVersionUsageEvent event = new ApiVersionUsageEvent(version, clientId, Instant.now());
            applicationEventPublisher.publishEvent(event);
        }
        
        return true;
    }
    
    private String extractVersionFromRequest(HttpServletRequest request) {
        // Extract from header
        String headerVersion = request.getHeader("API-Version");
        if (headerVersion != null) {
            return headerVersion;
        }
        
        // Extract from parameter
        String paramVersion = request.getParameter("version");
        if (paramVersion != null) {
            return paramVersion;
        }
        
        // Extract from path
        String path = request.getRequestURI();
        if (path.contains("/v1/")) {
            return "v1";
        } else if (path.contains("/v2/")) {
            return "v2";
        }
        
        return versionManagementService.getCurrentVersion();
    }
}

DTO Version Mapping

java
// V1 DTOs
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserV1Request {
    @NotBlank
    private String name; // Single name field
    
    @Email
    @NotBlank
    private String email;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserV1Response {
    private Long id;
    private String name; // Single name field
    private String email;
    private Instant createdAt;
}

// V2 DTOs
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserV2Request {
    @NotBlank
    private String firstName; // Separate name fields
    
    @NotBlank
    private String lastName;
    
    @Email
    @NotBlank
    private String email;
    
    private String phone; // New field
    private UserProfileRequest profile; // New object
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserV2Response {
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private String phone;
    private UserProfileResponse profile;
    private Instant createdAt;
    private Instant updatedAt;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileRequest {
    private String bio;
    private String website;
    private String location;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileResponse {
    private String bio;
    private String website;
    private String location;
    private String avatarUrl;
}

This implementation integrates different approaches to API versioning with the Spring Boot ecosystem and provides production-ready solutions.

Created by Eren Demir.