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 standard Deprecation 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 single author 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.
Before Spring Framework 7, each project built its own versioning on top of request mappings. Spring 7 has it built in: a 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 single author. 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);
  }
}
The versioning strategy itself is configured with Spring Boot properties - here on the command line, normally in 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
 OUTPUT of requests with different API-Version headers
> 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.header told Spring to read the version from the API-Version header. The first request sent none, so the default version, 1.0, applied and bookV1() answered with a single author.
  • With API-Version: 2.0, the same URL reached bookV2(): 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 400 problem. Its detail shows the version as Spring parsed it, 3.0.0. spring.mvc.apiversion.supported lists them explicitly instead.
  • two cannot 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 - so 2, 2.0 and 2.0.0 are the same version.
  • With spring.mvc.apiversion.required=true and 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 a WebMvcConfigurer, 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);
  }
}
 OUTPUT of requests to /api/v1 and /api/v2
> 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}/books keeps that segment in the URL pattern, so the controller's paths match for every version.
  • VPrefixVersionParser, set with setVersionParser(), strips a leading v and then parses the rest with Spring's SemanticApiVersionParser, so /api/v2/ and /api/2/ both mean version 2. With the default parser alone, v2 would 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), Sunset with the date it will stop working, and a Link to 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 400 problem.

Sending a version from a client

Spring's HTTP clients know about versions too. A RestClient 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 the API-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.