API Versioning

Version endpoints with Spring Framework 7's built-in API versioning: read the version from a header or path, map it on handlers, deprecate old ones.

Syntaxspring.mvc.apiversion.use.header=API-Version @GetMapping(path = "/{id}", version = "1.0") OrderV1 getV1(...) @GetMapping(path = "/{id}", version = "2.0") OrderV2 getV2(...) @GetMapping(path = "/{id}", version = "1.1+") ... // 1.1 and later

Once other teams or customers depend on your API, you cannot change a response shape without breaking them. Versioning lets v1 and v2 of an endpoint run side by side while clients move over. Spring Framework 7 added first-class support for this, so you no longer have to fake it with URL prefixes and duplicated controllers.

Two parts: where the version comes from, and which handler it picks

First, tell Spring how to read the version from the request. In Spring Boot use properties under spring.mvc.apiversion, or implement configureApiVersioning in a WebMvcConfigurer. The choices are a request header, a query parameter, a path segment or a media type parameter.

spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.supported=1.0,2.0

Then add version to the mapping annotations: @GetMapping(path = "/{id}", version = "2.0"). Versions are parsed as major.minor.patch, missing parts count as zero and a leading v is ignored, so 1, 1.0 and v1 are the same version. A mapping without version matches any version, which suits endpoints that never changed.

A version ending in + is a baseline: "1.1+" matches 1.1 and anything later, until a mapping with a higher version takes over. That means you only write a new handler for endpoints that really changed in a release.

What clients get when it goes wrong

  • A version the server does not support: 400 with InvalidApiVersionException. Versions declared on mappings are supported automatically; list any others, such as versions only reached through a baseline, under supported.
  • No version at all: 400 with MissingApiVersionException, because a version is required by default. Set a default version to accept unversioned requests instead; the two settings cannot be combined.

Choosing a strategy

A header keeps URLs stable and is easy for API clients. A path segment (/api/v2/orders) is visible in logs and easy to try in a browser, but the mapping must include the segment as a variable, for example /api/{version}/orders. Query parameters are simple but end up in caches keyed by URL. Pick one and use it for the whole API.

Retiring versions

A StandardApiVersionDeprecationHandler adds Deprecation, Sunset and Link headers to responses for old versions, so clients learn about the retirement from every call rather than from an email they missed. Track how many requests still use the old version before you remove it.

Example

Example · java
import java.math.BigDecimal;

import org.springframework.context.annotation.Configuration;
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;

@Configuration
class ApiVersionConfig implements WebMvcConfigurer {

    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.useRequestHeader("API-Version")
                .addSupportedVersions("1.0", "2.0")
                // Unversioned requests are treated as 1.0, so old clients keep working.
                .setDefaultVersion("1.0");
    }
}

// v1 sent the total as a string with a currency symbol.
record OrderV1(long id, String total) {}

// v2 sends a number and a separate currency code.
record OrderV2(long id, BigDecimal total, String currency) {}

@RestController
@RequestMapping("/api/orders")
public class VersionedOrderController {

    @GetMapping(path = "/{id}", version = "1.0")
    public OrderV1 getV1(@PathVariable long id) {
        return new OrderV1(id, "£42.50");
    }

    @GetMapping(path = "/{id}", version = "2.0")
    public OrderV2 getV2(@PathVariable long id) {
        return new OrderV2(id, new BigDecimal("42.50"), "GBP");
    }
}

When to use it

  • Change an order's total from a formatted string to a number for new clients while the old mobile app keeps receiving v1.
  • Release API 1.3 with one changed endpoint and let every unchanged endpoint keep serving through a 1.0+ baseline.
  • Warn integrators six months ahead that v1 will be switched off, using Deprecation and Sunset headers on every v1 response.

More examples

The same setup with properties only

Spring Boot applies these to Spring MVC for you, so a configuration class is only needed for things the properties do not cover, such as a deprecation handler. <code>path-segment</code> is a zero-based index: for <code>/api/v2/orders</code>, index 0 is <code>api</code> and index 1 is <code>v2</code>. Setting a default and also <code>spring.mvc.apiversion.required=true</code> fails at startup, since the two contradict each other.

Example · properties
# Read the version from the API-Version request header
spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.supported=1.0,2.0
spring.mvc.apiversion.default=1.0

# Alternatives (choose one per API):
# spring.mvc.apiversion.use.query-parameter=version
# spring.mvc.apiversion.use.path-segment=1

Baseline versions for endpoints that rarely change

Without baselines, releasing 1.4 would mean touching every controller just to add <code>"1.4"</code>. With them, an endpoint only gets a new method when its contract changes. Remember that the versions clients send still have to be supported: if nothing declares 1.4 exactly, add it to the supported list or requests for it get 400.

Example · java
import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

record Category(String name) {}

record CategoryWithCount(String name, int productCount) {}

@RestController
@RequestMapping("/api/categories")
public class CategoryController {

    // Serves 1.0 and every later version...
    @GetMapping(version = "1.0+")
    public List<Category> list() {
        return List.of(new Category("kitchen"), new Category("office"));
    }

    // ...until 1.3, which added product counts. 1.3, 1.4, 2.0 all land here.
    @GetMapping(version = "1.3+")
    public List<CategoryWithCount> listWithCounts() {
        return List.of(new CategoryWithCount("kitchen", 12), new CategoryWithCount("office", 4));
    }
}

Announcing deprecation and sunset

Every response to a 1.0 request now carries <code>Deprecation</code> and <code>Sunset</code> headers plus a <code>Link</code> to the migration guide. Well-behaved client libraries and API gateways can surface these as warnings. Pair the headers with a metric on requested versions so you can see when 1.0 traffic has dropped far enough to remove it.

Example · java
import java.net.URI;
import java.time.ZonedDateTime;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.accept.StandardApiVersionDeprecationHandler;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class ApiDeprecationConfig implements WebMvcConfigurer {

    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler();
        handler.configureVersion("1.0")
                .setDeprecationDate(ZonedDateTime.parse("2026-09-01T00:00:00Z"))
                .setSunsetDate(ZonedDateTime.parse("2027-03-01T00:00:00Z"))
                .setSunsetLink(URI.create("https://api.example.com/docs/migrating-to-v2"));

        configurer.useRequestHeader("API-Version")
                .addSupportedVersions("1.0", "2.0")
                .setDefaultVersion("1.0")
                .setDeprecationHandler(handler);
    }
}

Discussion

  • Be the first to comment on this lesson.