API Versioning in Spring Framework 7 and Spring Boot 4
Introduction
This tutorial covers API versioning, built into Spring Framework 7 and Spring Boot 4: serving several versions of the same endpoint side by side, so that an API can change without breaking the clients that depend on it. We will start with why and when an API needs versions, and the four common ways a client can ask for one. We will then version a book endpoint through a request header, configured entirely with Spring Boot properties, and see how Spring picks the right controller method - and what happens with a missing, an older or an unknown version. Next we will switch to versions in the URL path with Java configuration, and announce the retirement of an old version with standardDeprecation and Sunset headers. Lastly, we will see how a client sends a version with RestClient, and how to choose a strategy.(This tutorial uses the problem-detail error format from REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail.)
Why version an API?
Once clients use an API - a mobile app in users' pockets, another team's service - every change to it is a risk. Many changes are safe and need no new version: adding an endpoint, adding an optional request field, adding a field to a response that clients can ignore. Breaking changes are different: removing or renaming a field, changing its type (a singleauthor string becoming a list of authors), changing what a status code means. For those, the API offers a new version alongside the old one, and old clients keep working until they move. The client says which version it wants in one of four places -
- A request header:
API-Version: 2.0. The URL stays the same. - The URL path:
/api/v2/books/1. Visible, and easy to try in a browser. - A query parameter:
/api/books/1?version=2.0. - A media type parameter:
Accept: application/json;version=2.0.
version attribute on every mapping annotation, and a configurable strategy that resolves, parses and validates the version of each request.
Versioning with a request header
In version 1.0 of our API, a book has a singleauthor. Version 2.0 replaces it with a list of authors and adds the ISBN - a breaking change. Each version gets its own handler method, marked with the version attribute; the list endpoint did not change, so it is marked "1.0+", meaning version 1.0 and every later one -
HeaderVersioningApp.java - two versions of one endpoint, selected by a header
//HeaderVersioningApp.java
package com.javabrahman.rest.versioning.header;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
record BookV1(long id, String title, String author) {
}
record BookV2(long id, String title, List<String> authors, String isbn) {
}
@RestController
@RequestMapping("/api/books")
class BookController {
@GetMapping(path = "/{id}", version = "1.0")
BookV1 bookV1(@PathVariable long id) {
return new BookV1(id, "Modern Java in Action", "Raoul-Gabriel Urma");
}
@GetMapping(path = "/{id}", version = "2.0")
BookV2 bookV2(@PathVariable long id) {
return new BookV2(id, "Modern Java in Action",
List.of("Raoul-Gabriel Urma", "Mario Fusco", "Alan Mycroft"), "9781617293566");
}
@GetMapping(version = "1.0+") // unchanged since 1.0: serves 1.0, 2.0 and any later version
List<String> titles() {
return List.of("Effective Java", "Modern Java in Action");
}
}
@SpringBootApplication
public class HeaderVersioningApp {
public static void main(String[] args) {
SpringApplication.run(HeaderVersioningApp.class, args);
}
}
application.properties -
application.properties - header-based versioning
spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.default=1.0
spring.mvc.problemdetails.enabled=true
> curl -i http://localhost:8080/api/books/1
HTTP/1.1 200
Content-Length: 70
Content-Type: application/json
{"id":1,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma"}
> curl -i http://localhost:8080/api/books/1 -H "API-Version: 2.0"
HTTP/1.1 200
Content-Length: 125
Content-Type: application/json
{"id":1,"title":"Modern Java in Action","authors":["Raoul-Gabriel Urma","Mario Fusco","Alan Mycroft"],"isbn":"9781617293566"}
> curl -i http://localhost:8080/api/books -H "API-Version: 2.0"
HTTP/1.1 200
Content-Length: 42
Content-Type: application/json
["Effective Java","Modern Java in Action"]
> curl -i http://localhost:8080/api/books/1 -H "API-Version: 3.0"
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Invalid API version: '3.0.0'.","instance":"/api/books/1","status":400,"title":"Bad Request"}
> curl -i http://localhost:8080/api/books/1 -H "API-Version: two"
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Invalid API version: 'two'.","instance":"/api/books/1","status":400,"title":"Bad Request"}
Explanation of the output
spring.mvc.apiversion.use.headertold Spring to read the version from theAPI-Versionheader. The first request sent none, so thedefaultversion, 1.0, applied andbookV1()answered with a single author.- With
API-Version: 2.0, the same URL reachedbookV2(): a list of authors and an ISBN. - The list endpoint, mapped with
"1.0+", answered version 2.0 too, without a second method. A baseline version saves you from copying every unchanged endpoint each time a version is added. - Version 3.0 is not one the controllers declare. Spring Boot detects the supported versions from the mappings (1.0 and 2.0 here) and rejected 3.0 with a
400problem. Its detail shows the version as Spring parsed it,3.0.0.spring.mvc.apiversion.supportedlists them explicitly instead. twocannot be parsed as a version at all, so it was rejected the same way. By default versions are parsed as semantic versions - major, minor and patch numbers - so2,2.0and2.0.0are the same version.- With
spring.mvc.apiversion.required=trueand no default, a request without a version would be rejected instead of falling back to 1.0.
Versioning in the URL path, with Java configuration
The same mechanism works with the version in the path. Instead of properties, this example uses aWebMvcConfigurer, which offers every option of the strategy, including a deprecation handler that tells clients when an old version will be retired -
PathVersioningApp.java - versions in the path, and a deprecated version 1
//PathVersioningApp.java
package com.javabrahman.rest.versioning.path;
import java.net.URI;
import java.time.ZonedDateTime;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.accept.StandardApiVersionDeprecationHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
record BookV1(long id, String title, String author) {
}
record BookV2(long id, String title, List<String> authors) {
}
// Accepts "v2" as well as "2", then parses it as a semantic version like the default parser
class VPrefixVersionParser implements ApiVersionParser<SemanticApiVersionParser.Version> {
private final SemanticApiVersionParser semantic = new SemanticApiVersionParser();
@Override
public SemanticApiVersionParser.Version parseVersion(String version) {
return semantic.parseVersion(version.startsWith("v") ? version.substring(1) : version);
}
}
@Configuration
class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
StandardApiVersionDeprecationHandler deprecation = new StandardApiVersionDeprecationHandler();
deprecation.configureVersion("1")
.setDeprecationDate(ZonedDateTime.parse("2026-11-01T00:00:00Z"))
.setSunsetDate(ZonedDateTime.parse("2027-05-01T00:00:00Z"))
.setSunsetLink(URI.create("https://api.example.com/docs/migrating-to-v2"));
configurer.usePathSegment(1) // /api/{version}/... : segment 0 is "api", segment 1 the version
.setVersionParser(new VPrefixVersionParser())
.addSupportedVersions("1", "2")
.setDeprecationHandler(deprecation);
}
}
@RestController
@RequestMapping("/api/{version}/books")
class BookController {
@GetMapping(path = "/{id}", version = "1")
BookV1 bookV1(@PathVariable long id) {
return new BookV1(id, "Effective Java", "Joshua Bloch");
}
@GetMapping(path = "/{id}", version = "2")
BookV2 bookV2(@PathVariable long id) {
return new BookV2(id, "Effective Java", List.of("Joshua Bloch"));
}
}
@SpringBootApplication
public class PathVersioningApp {
public static void main(String[] args) {
SpringApplication.run(PathVersioningApp.class, args);
}
}
> curl -i http://localhost:8080/api/v2/books/7
HTTP/1.1 200
Content-Length: 60
Content-Type: application/json
{"id":7,"title":"Effective Java","authors":["Joshua Bloch"]}
> curl -i http://localhost:8080/api/v1/books/7
HTTP/1.1 200
Deprecation: @1793491200
Sunset: Sat, 1 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/migrating-to-v2>; rel="sunset"; type="text/html"
Content-Length: 57
Content-Type: application/json
{"id":7,"title":"Effective Java","author":"Joshua Bloch"}
> curl -i http://localhost:8080/api/v9/books/7
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Invalid API version: '9.0.0'.","instance":"/api/v9/books/7","status":400,"title":"Bad Request"}
Explanation of the output
usePathSegment(1)takes the version from the second segment of the path. The mapping/api/{version}/bookskeeps that segment in the URL pattern, so the controller's paths match for every version.VPrefixVersionParser, set withsetVersionParser(), strips a leadingvand then parses the rest with Spring'sSemanticApiVersionParser, so/api/v2/and/api/2/both mean version 2. With the default parser alone,v2would be rejected as unparseable.- Version 2 answered normally. Version 1 still worked, but its response carried three extra headers from the deprecation handler:
Deprecation, with the date version 1 was deprecated (as a structured-field date,@followed by seconds since 1970),Sunsetwith the date it will stop working, and aLinkto the migration guide. These are standard headers (RFC 9745 and RFC 8594) that API gateways and client libraries understand, so clients learn about the retirement long before it happens. - Version 9 is not supported: a
400problem.
Sending a version from a client
Spring's HTTP clients know about versions too. ARestClient can be built with an ApiVersionInserter that puts the version where the server expects it, and a default version for every request -
Sending the API version with RestClient
RestClient client = RestClient.builder()
.baseUrl("http://localhost:8080")
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version"))
.defaultApiVersion("2.0")
.build();
BookV2 book = client.get().uri("/api/books/1").retrieve().body(BookV2.class); // sends API-Version: 2.0
BookV1 old = client.get().uri("/api/books/1").apiVersion("1.0").retrieve().body(BookV1.class);
ApiVersionInserter also has useQueryParam(), usePathSegment() and useMediaTypeParam(). RestClient itself is covered in Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces.
Choosing a strategy
- Path (
/api/v2/...): the most visible and the easiest to test, cache and route in a gateway; the URL of a resource changes between versions. - Header (
API-Version: 2): clean, stable URLs; the version is less visible, and caches must be told that responses vary by the header. - Query parameter: simple, but mixes the version with the resource's own parameters.
- Media type parameter: the most "pure" HTTP approach, but the least convenient for clients.
- Whichever you choose: version only for breaking changes, keep the number of live versions small, use baselines (
"1.0+") for unchanged endpoints, and announce retirements early with deprecation headers and documentation.
Summary
In this tutorial we first saw why APIs need versions only for breaking changes, and the four places a client can put the version. We then versioned an endpoint through theAPI-Version header with the version attribute of @GetMapping and spring.mvc.apiversion.* properties, used a "1.0+" baseline for an unchanged endpoint, and saw default, unsupported and unparseable versions handled with problem responses. Next we moved the version into the URL path with ApiVersionConfigurer, accepted v2 with a custom parser, and announced the retirement of version 1 with Deprecation, Sunset and Link headers. Lastly, we sent versions from RestClient and compared the strategies. The next tutorial deals with large collections: pagination, sorting and filteringClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs.Spring REST API Tutorials
REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces
REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces