As software architects, we often face the challenge of evolving our APIs without breaking the contracts relied upon by existing clients. For years, Spring developers had to rely on manual workarounds—custom RequestCondition implementations, path pattern matching, or third-party libraries—to handle versioning.
With the release of Spring Boot 4 (built on Spring Framework 7), native support for API versioning has finally arrived. The new @ApiVersion annotation standardizes how we manage the lifecycle of our endpoints, making our codebases cleaner and more maintainable.
In this post, we will explore how to configure and implement this new feature using a clean, architectural approach.
1. The Strategy: Header vs. Path vs. Media Type
Before writing code, we must choose a versioning strategy. Spring Boot 4 supports multiple strategies, but three stand out for different architectural needs:
- Header Versioning (Recommended): Clients send a custom header (e.g.,
X-API-VERSION: 1). This keeps URLs clean and separates metadata from the resource identifier. - Media Type Versioning (The Purist’s Choice): Clients specify the version in the
Acceptheader (e.g.,Accept: application/json;version=1). This strictly adheres to REST principles: the resource URI is constant, but the representation changes. - Path Versioning: The version is embedded in the URI (
/api/v1/customers). This is pragmatic and cache-friendly, though it technically changes the resource identity.
Note: Spring also supports Query Parameter versioning (?version=1), but we strictly avoid this to keep request parameters reserved for filtering/sorting logic, especially on GET requests.
2. Configuration
First, ensure you are using the latest Spring Boot 4 starter in your build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
Next, we need to configure the ApiVersionStrategy. You can choose one of the following strategies based on your architecture.
Option A: Header Configuration (Standard)
This configuration keeps your URLs clean.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// Use a custom header for versioning
.useRequestHeader("X-API-VERSION")
.setDefaultVersion("1")
.useDefaultVersionWhenMissing();
}
}
Option B: Media Type Configuration (Architect’s Choice)
This uses standard content negotiation.
import org.springframework.http.MediaType;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// Client sends: Accept: application/json;version=1
.useMediaTypeParameter(MediaType.APPLICATION_JSON, "version")
.setDefaultVersion("1");
}
}
Option C: Path Configuration (Pragmatic Choice)
If you prefer versions in the URL (e.g., /api/v1/customers), use this configuration.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// Extract version from the 2nd path segment (index 1)
// Example: /api/v1/customers -> "v1" is at index 1
.usePathSegment(1)
.setDefaultVersion("1");
}
}
3. Implementation with @ApiVersion
The @ApiVersion annotation works the same way regardless of your configuration, but your Controller’s @RequestMapping must align with your strategy.
Implementation for Header & Media Type Strategies
Both of these strategies keep the URL path clean.
import org.springframework.web.bind.annotation.*;
import org.springframework.core.annotation.ApiVersion;
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
// ---------------------------------------------------------
// VERSION 1: Legacy Structure
// ---------------------------------------------------------
@ApiVersion("1")
@GetMapping("/{id}")
public CustomerV1 getCustomerV1(@PathVariable String id) {
return customerService.findV1(id);
}
// ---------------------------------------------------------
// VERSION 2: Modern Structure
// ---------------------------------------------------------
@ApiVersion("2")
@GetMapping("/{id}")
public CustomerV2 getCustomerV2(@PathVariable String id) {
return customerService.findV2(id);
}
}
Implementation for Path Strategy
If you chose Option C (Path), you must include the version variable in your route path.
@RestController
// Notice the {version} placeholder in the path
@RequestMapping("/api/{version}/customers")
public class CustomerController {
@ApiVersion("1")
@GetMapping("/{id}")
public CustomerV1 getCustomerV1(@PathVariable String id) {
return customerService.findV1(id);
}
@ApiVersion("2")
@GetMapping("/{id}")
public CustomerV2 getCustomerV2(@PathVariable String id) {
return customerService.findV2(id);
}
}
4. Testing the API
Here is how you request specific versions depending on your chosen strategy.
Testing Header Strategy:
curl -X GET http://localhost:8080/api/customers/101 \
-H "X-API-VERSION: 2"
Testing Media Type Strategy:
curl -X GET http://localhost:8080/api/customers/101 \
-H "Accept: application/json;version=2"
Testing Path Strategy:
curl -X GET http://localhost:8080/api/2/customers/101
Conclusion
Native versioning in Spring Boot 4 removes the need for complex AOP wrappers. Whether you prefer the cleanliness of Header/Media Type Versioning or the explicitness of Path Versioning, the @ApiVersion annotation provides a unified way to manage your API’s lifecycle.
Discover more from GhostProgrammer - Jeff Miller
Subscribe to get the latest posts sent to your email.
