This article explains how to use Spring Boot built-in API versioning feature to expose different versions of REST endpoints. This is one of the most interesting updates introduced with Spring Boot 4. API versioning can be implemented using Spring Web’s standard REST API capabilities. If you’re interested in this approach, check out my somewhat outdated article on the subject here.
Interestingly, the Micronaut framework also provides built-in API versioning. You can read more about it in the framework’s documentation here.
Source Code
Feel free to use my source code if you’d like to try it out yourself. To do that, you must clone my sample GitHub repository. Then you should only follow my instructions.
Introduction
The Spring Boot example application discussed in this article features two versions of the data model returned by the API. Below is the basic structure of the Person object, which is shared across all API versions.
public abstract class Person {
private Long id;
private String name;
private Gender gender;
public Person() {
}
public Person(Long id, String name, Gender gender) {
this.id = id;
this.name = name;
this.gender = gender;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Gender getGender() {
return gender;
}
public void setGender(Gender gender) {
this.gender = gender;
}
}
Java
The scenario assumes that we choose the option that returns the same age for a person in two different ways. This is a somewhat pessimistic version, but it is the one we want to examine. In the first method, we return JSON containing the birthdate. In the second method, we return the age field. Below is the PersonOld object implementing the first approach.
@Schema(name = "Person")
public class PersonOld extends Person {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate birthDate;
public PersonOld() {
}
public PersonOld(Long id, String name, Gender gender, LocalDate birthDate) {
super(id, name, gender);
this.birthDate = birthDate;
}
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
}
Java
Here, we see the PersonCurrent object, which contains the age field instead of the previously used birthDate.
@Schema(name = "Person")
public class PersonCurrent extends Person {
private int age;
public PersonCurrent() {
}
public PersonCurrent(Long id, String name, Gender gender, int age) {
super(id, name, gender);
this.age = age;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
Java
Design API for Versioning with Spring Boot
API Methods
Now we can design an API that supports different object versions on one hand and two distinct versioning methods on the other. In the first method, we will use the HTTP header, and in the second, the request path. For clarity, below is a table of REST API methods for HTTP header-based versioning.
| Method type | Method path | Description |
|---|---|---|
| POST | /persons | Add a new person, v1.2 for PersonCurrent, v1.[0-1] for PersonOld |
| PUT | /persons/{id} | Update a person, v1.2 for PersonCurrent, v1.1 for PersonOld |
| DELETE | /persons/{id} | Delete a person |
| GET | /persons/{id} | Find a person by ID, v1.2 for PersonCurrent |
Here, in turn, is a table for versioning based on the request path.
| Method type | Method path | Description |
|---|---|---|
| POST | /persons/v1.0, /persons/v1.1 | Add a new person (PersonOld) |
| POST | /persons/v1.2 | Add a new person (PersonCurrent) |
| PUT | /persons/v1.0 | Update a person – v1.0 deprecated |
| PUT | /persons/v1.1/{id} | Update a person with ID (PersonOld) |
| PUT | /persons/v1.2/{id} | Update a person with ID (PersonCurrent) |
| DELETE | /persons/v1.0, /persons/v1.1, … | Delete a person |
| GET | /persons/v1.0/{id}, /persons/v1.1 | Find a person by ID, v1.0[1] for PersonOld |
| GET | /persons/v1.2/{id} | Find a person by ID, v1.2 for PersonCurrent |
Spring Boot Implementation
To enable the built-in API versioning mechanism in Spring Web MVC, use spring.mvc.apiversion.* properties. The following configuration defines both of the API versioning methods mentioned above. In the header-based method, set its name. The header name used for testing purposes is api-version. In request path versioning, we must set the index of the path segment dedicated to the field with version. In our case, it is 1, because the version is read from the segment after the 0th element in the path, which is /persons. Please note that the two types of versions are only activated for testing purposes. Typically, you should select and use one API versioning method.
spring:
mvc:
apiversion:
default: v1.0
use:
header: api-version
path-segment: 1
Plaintext
Let’s continue by implementing individual API controllers. We use the @RestController approach for each versioning method. Now, in each annotation that specifies an HTTP method, we can include the version field. The mechanism maps the api-version header to the version field in the annotation. We can use syntax like v1.0+ to specify a version higher than v1.0.
@RestController
@RequestMapping("/persons-via-headers")
public class PersonControllerWithHeaders {
@Autowired
PersonMapper mapper;
@Autowired
PersonRepository repository;
@PostMapping(version = "v1.0+")
public PersonOld add(@RequestBody PersonOld person) {
return (PersonOld) repository.add(person);
}
@PostMapping(version = "v1.2")
public PersonCurrent add(@RequestBody PersonCurrent person) {
return (PersonCurrent) repository.add(person);
}
@PutMapping(version = "v1.0")
@Deprecated
public PersonOld update(@RequestBody PersonOld person) {
return (PersonOld) repository.update(person);
}
@PutMapping(value = "/{id}", version = "v1.1")
public PersonOld update(@PathVariable("id") Long id, @RequestBody PersonOld person) {
return (PersonOld) repository.update(person);
}
@PutMapping(value = "/{id}", version = "v1.2")
public PersonCurrent update(@PathVariable("id") Long id, @RequestBody PersonCurrent person) {
return mapper.map((PersonOld) repository.update(person));
}
@GetMapping(value = "/{id}", version = "v1.0+")
public PersonOld findByIdOld(@PathVariable("id") Long id) {
return (PersonOld) repository.findById(id);
}
@GetMapping(value = "/{id}", version = "v1.2")
public PersonCurrent findById(@PathVariable("id") Long id) {
return mapper.map((PersonOld) repository.findById(id));
}
@DeleteMapping("/{id}")
public void delete(@PathVariable("id") Long id) {
repository.delete(id);
}
}
Java
Then, we can implement a similar approach, but this time based on the request path. Here’s our @RestController.
@RestController
@RequestMapping("/persons")
public class PersonController {
@Autowired
PersonMapper mapper;
@Autowired
PersonRepository repository;
@PostMapping(value = "/{version}", version = "v1.0+")
public PersonOld add(@RequestBody PersonOld person) {
return (PersonOld) repository.add(person);
}
@PostMapping(value = "/{version}", version = "v1.2")
public PersonCurrent add(@RequestBody PersonCurrent person) {
return (PersonCurrent) repository.add(person);
}
@PutMapping(value = "/{version}", version = "v1.0")
@Deprecated
public PersonOld update(@RequestBody PersonOld person) {
return (PersonOld) repository.update(person);
}
@PutMapping(value = "/{version}/{id}", version = "v1.1")
public PersonOld update(@PathVariable("id") Long id, @RequestBody PersonOld person) {
return (PersonOld) repository.update(person);
}
@PutMapping(value = "/{version}/{id}", version = "v1.2")
public PersonCurrent update(@PathVariable("id") Long id, @RequestBody PersonCurrent person) {
return mapper.map((PersonOld) repository.update(person));
}
@GetMapping(value = "/{version}/{id}", version = "v1.0+")
public PersonOld findByIdOld(@PathVariable("id") Long id) {
return (PersonOld) repository.findById(id);
}
@GetMapping(value = "/{version}/{id}", version = "v1.2")
public PersonCurrent findById(@PathVariable("id") Long id) {
return mapper.map((PersonOld) repository.findById(id));
}
@DeleteMapping(value = "/{version}/{id}", version = "v1.0+")
public void delete(@PathVariable("id") Long id) {
repository.delete(id);
}
}
Java
Let’s start our application using the command below.
mvn spring-boot:run
ShellSession
We can test the REST endpoints of both controllers using the following curl commands. Below are the calls and the expected results.
$ curl http://localhost:8080/persons/v1.1/1
{"id":1,"name":"John Smith","gender":"MALE","birthDate":"1977-01-20"}
$ curl http://localhost:8080/persons/v1.2/1
{"id":1,"name":"John Smith","gender":"MALE","age":48}
$ curl -X POST http://localhost:8080/persons/v1.0 -d "{\"id\":1,\"name\":\"John Smith\",\"gender\":\"MALE\",\"birthDate\":\"1977-01-20\"}" -H "Content-Type: application/json"
{"id":6,"name":"John Smith","gender":"MALE","birthDate":"1977-01-20"}
$ curl -X POST http://localhost:8080/persons/v1.2 -d "{\"name\":\"John Smith\",\"gender\":\"MALE\",\"age\":40}" -H "Content-Type: application/json"
{"id":7,"name":"John Smith","gender":"MALE","age":40}
ShellSession
Testing API versioning with Spring Boot REST client
Importantly, Spring also offers support for versioning on the HTTP client side. This applies to both RestClient and WebClient, as well as their testing implementations. I don’t know if you’ve had a chance to use RestTestClient in your tests yet. After initializing the client instance, set the versioning method using apiVersionInserter. Then, when calling a given HTTP method, you can set the version number by calling apiVersion(...) with the version number as an argument. Below is a class that tests versioning using an HTTP header.
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerWithHeadersTests {
private WebApplicationContext context;
private RestTestClient restTestClient;
@BeforeEach
public void setup(WebApplicationContext context) {
restTestClient = RestTestClient.bindToApplicationContext(context)
.baseUrl("/persons-via-headers")
.apiVersionInserter(ApiVersionInserter.useHeader("api-version"))
.build();
}
@Test
@Order(1)
void addV0() {
restTestClient.post()
.body(Instancio.create(PersonOld.class))
.apiVersion("v1.0")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(PersonOld.class)
.value(personOld -> assertNotNull(personOld.getId()));
}
@Test
@Order(2)
void addV2() {
restTestClient.post()
.body(Instancio.create(PersonCurrent.class))
.apiVersion("v1.2")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
@Test
@Order(3)
void findByIdV0() {
restTestClient.get()
.uri("/{id}", 1)
.apiVersion("v1.0")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(PersonOld.class)
.value(personOld -> assertNotNull(personOld.getId()));
}
@Test
@Order(3)
void findByIdV2() {
restTestClient.get()
.uri("/{id}", 2)
.apiVersion("v1.2")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
@Test
@Order(3)
void findByIdV2ToV1Compability() {
restTestClient.get()
.uri("/{id}", 1)
.apiVersion("v1.2")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
}
Java
And here are similar tests, but this time for versioning based on the request path.
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerTests {
private WebApplicationContext context;
private RestTestClient restTestClient;
@BeforeEach
public void setup(WebApplicationContext context) {
restTestClient = RestTestClient.bindToApplicationContext(context)
.baseUrl("/persons")
.apiVersionInserter(ApiVersionInserter.usePathSegment(1))
.build();
}
@Test
@Order(1)
void addV0() {
restTestClient.post()
.apiVersion("v1.1")
.body(Instancio.create(PersonOld.class))
.exchange()
.expectBody(PersonOld.class)
.value(personOld -> assertNotNull(personOld.getId()));
}
@Test
@Order(2)
void addV2() {
restTestClient.post()
.apiVersion("v1.2")
.body(Instancio.create(PersonCurrent.class))
.exchange()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
@Test
@Order(3)
void findByIdV0() {
restTestClient.get().uri("/{id}", 1)
.apiVersion("v1.0")
.exchange()
.expectBody(PersonOld.class)
.value(personOld -> assertNotNull(personOld.getId()));
}
@Test
@Order(3)
void findByIdV2() {
restTestClient.get().uri("/{id}", 2)
.apiVersion("v1.2")
.exchange()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
@Test
@Order(3)
void findByIdV2ToV1Compability() {
restTestClient.get().uri("/{id}", 1)
.apiVersion("v1.2")
.exchange()
.expectBody(PersonCurrent.class)
.value(personCurrent -> assertNotNull(personCurrent.getId()))
.value(personCurrent -> assertTrue(personCurrent.getAge() > 0));
}
@Test
@Order(4)
void delete() {
restTestClient.delete().uri("/{id}", 5)
.apiVersion("v1.2")
.exchange()
.expectStatus().is2xxSuccessful();
}
}
Java
Here are my test results.
OpenAPI for Spring Boot API versioning
I also tried to check what support for API versioning looks like on the Springdoc side. This project provides an OpenAPI implementation for Spring MVC. For Spring Boot 4, we must use at least 3.0.0 version of Springdoc.
org.springdoc
springdoc-openapi-starter-webmvc-ui
3.0.0
XML
My goal was to divide the API into groups based on version for a path segment approach. Unfortunately, attempting this type of implementation results in an HTTP 400 response for both the /v3/api-docs and /swagger-ui.html URLs. That’s why I created an issue in Springdoc GitHub repository here. Once they fixed problems or eventually explain what I should improve in my implementation, I’ll update the article.
@Bean
public GroupedOpenApi personApiViaHeaders() {
return GroupedOpenApi.builder()
.group("person-via-headers")
.pathsToMatch("/persons-via-headers/**")
.build();
}
@Bean
public GroupedOpenApi personApi10() {
return GroupedOpenApi.builder()
.group("person-api-1.0")
.pathsToMatch("/persons/v1.0/**")
.build();
}
@Bean
public GroupedOpenApi personApi11() {
return GroupedOpenApi.builder()
.group("person-api-1.1")
.pathsToMatch("/persons/v1.1/**")
.build();
}
@Bean
public GroupedOpenApi personApi12() {
return GroupedOpenApi.builder()
.group("person-api-1.2")
.pathsToMatch("/persons/v1.2/**")
.build();
}
Java
Conclusion
Built-in API versioning support is one of the main features in Spring Boot 4. It works very smoothly. Importantly, API versioning is supported on both the server and client sides. We can also easily integrate it in JUnit tests with RestTestClient and WebTestClient. This article demonstrates Spring MVC implementation, but you can also use the built-in versioning API for Spring Boot applications based on the reactive WebFlux stack.

