A client team pinged me last quarter asking why their integration broke overnight. I had shipped a perfectly reasonable change: an /accounts/{id} endpoint that used to return a flat name field now returned a nested contact.email object instead. Nothing was deprecated, nothing was announced — I just shipped it, because until Spring Framework 7.0 there was no first-class way to run two shapes of the same endpoint side by side without hand-rolling the routing yourself.
That gap is closed now. Framework 7.0 (GA 13 November 2025) ships built-in API versioning: a version attribute on @RequestMapping and friends, server-side resolution from a header, query param, media type, or path segment, deprecation headers per RFC 9745/8594, and matching support on the client side with RestClient, WebClient, and HTTP interface clients. I rebuilt that exact incident as a working project to see precisely how the mechanics behave — including a couple of things the official docs don’t spell out — and this guide walks through all of it, beginner to advanced, with real requests and real responses.
Where this guide is going
Nine sections, each one building on the last:
- 1. What API versioning actually solves — the problem, in plain terms.
- 2. Your first versioned endpoint — the
versionattribute, header-based resolution, real requests. - 3. Baseline versions with + — serving several versions from one handler.
- 4. The supported-versions gotcha — a baseline handler does not mean what you think it means.
- 5. Deprecation headers — RFC-compliant
Deprecation,Sunset, andLinkheaders, for free. - 6. Client-side versioning —
RestClientandApiVersionInserter. - 7. HTTP interface clients —
@HttpExchangewith a version. - 8. Testing versioned APIs —
MockMvc,RestTestClient,WebTestClient. - 9. The migration checklist
What API versioning actually solves
Before Framework 7, “versioning a Spring API” meant one of three hand-rolled approaches: separate controller packages per version, a custom RequestCondition, or shoving version logic into an interceptor. All three work, all three are boilerplate, and none of them give you deprecation headers or client-side support for free. Spring’s own team called this out directly: implementing API versioning was possible, but required real effort for something that is, in the words of the feature’s own umbrella issue, a genuinely common need.
Framework 7 doesn’t take a position on whether you should version your API — that’s a design debate with strong opinions on both sides (Roy Fielding has argued against it; plenty of production APIs do it anyway). What it does is remove the excuse that versioning is too much plumbing to bother with. You get: a version attribute on @RequestMapping/@GetMapping/etc., pluggable resolution (header, query param, media type parameter, or path segment), semantic-version comparison out of the box, RFC-compliant deprecation headers, and matching client-side helpers. Let’s build the smallest possible version of it.
| Resolution Strategy | Configure With | Example |
|---|---|---|
| HTTP header | configurer.useRequestHeader("API-Version") | API-Version: 2 |
| Query parameter | configurer.useQueryParam("version") | /accounts/42?version=2 |
| Media type parameter | configurer.useMediaTypeParameter("application/json", "version") | Accept: application/json;version=2 |
| URL path segment | configurer.usePathSegment(int) | /api/2/accounts/42 |
Your first versioned endpoint
Two pieces: tell Spring where to look for the version, then attach a version to a mapping. For a Servlet (Spring MVC) app, configuration goes through WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("API-Version");
configurer.setVersionRequired(false);
configurer.addSupportedVersions("1", "1.1", "2");
}
}
Then the version attribute on the mapping annotation does the routing. Here are three versions of the same endpoint, each returning a different shape:
@RestController
public class AccountController {
@GetMapping(path = "/accounts/{id}", version = "1")
public Map<String, Object> getAccountV1(@PathVariable String id) {
return Map.of("id", id, "name", "Ankur Mhatre", "apiVersion", "1");
}
@GetMapping(path = "/accounts/{id}", version = "1.1+")
public Map<String, Object> getAccountV1Point1Plus(@PathVariable String id) {
return Map.of("id", id, "name", "Ankur Mhatre",
"email", "[email protected]", "apiVersion", "1.1+");
}
@GetMapping(path = "/accounts/{id}", version = "2")
public Map<String, Object> getAccountV2(@PathVariable String id) {
return Map.of("accountId", id, "displayName", "Ankur Mhatre",
"contact", Map.of("email", "[email protected]"), "apiVersion", "2");
}
}
I ran this exact controller on Spring Boot 4.1.0 / Framework 7.0.8 and hit it with real requests, one header value at a time:
> curl -H "API-Version: 1" http://localhost:8085/accounts/42
{"id":"42","name":"Ankur Mhatre","apiVersion":"1"}
> curl -H "API-Version: 2" http://localhost:8085/accounts/42
{"accountId":"42","displayName":"Ankur Mhatre","contact":{"email":"[email protected]"},"apiVersion":"2"}
> curl http://localhost:8085/accounts/42
{"accountId":"42","displayName":"Ankur Mhatre","contact":{"email":"[email protected]"},"apiVersion":"2"}
That last one is worth pausing on: with setVersionRequired(false) and no setDefaultVersion(...) call, a request with no version header didn’t get rejected and didn’t fall back to the oldest version — it resolved to the highest supported version, version 2. If you want new clients that forget to send a version header to land on your oldest, most conservative response shape instead, call configurer.setDefaultVersion("1") explicitly. Don’t assume — I checked this behavior precisely because most write-ups don’t mention it, and it’s exactly the kind of default that surprises you in production.
Baseline versions with +
Notice the middle handler above is mapped to version = "1.1+", not a fixed "1.1". The trailing + makes it a baseline version: it handles version 1.1 and continues to handle every higher version until a more specific handler exists. This is the feature’s answer to the usual versioning tax — you don’t need a new controller method for every version bump, only for the ones that actually change the response.
Concretely: with the three handlers above (1, 1.1+, 2) and version 1.1 requested, the 1.1+ method runs. Add a hypothetical version 1.2 with no new handler, and 1.2 would also route to 1.1+ — right up until either a dedicated 1.2 handler appears or the request hits 2, which takes over from there. One method, an open-ended range of versions, until you decide to split it.
The supported-versions gotcha
Here’s the part that isn’t obvious from the reference docs, and it cost me twenty minutes of confusion until I isolated it directly: the + in "1.1+" governs handler selection among versions your app already accepts. It does not mean “accept any version number greater than or equal to 1.1.” I proved this by requesting a version that sits between two declared numbers:
// WebConfig: configurer.addSupportedVersions("1", "1.1", "2");
// (note: 1.5 was never added to the supported list)
> curl -H "API-Version: 1.5" http://localhost:8085/accounts/42
HTTP/1.1 400 Bad Request
> curl -H "API-Version: 1.9" http://localhost:8085/accounts/42
HTTP/1.1 400 Bad Request
That happened even with configurer.detectSupportedVersions(true) enabled, which auto-derives supported versions from your declared mapping versions (1, 1.1, 2). Version 1.5 isn’t one of those three literal tokens, so it’s rejected outright — it never even reaches the routing logic that would otherwise happily send it to the 1.1+ handler. Once I added 1.5 and 1.9 to the supported list explicitly, the exact same request started resolving correctly:
// WebConfig: configurer.addSupportedVersions("1", "1.1", "1.5", "1.9", "2");
> curl -H "API-Version: 1.5" http://localhost:8085/accounts/42
{"id":"42","name":"Ankur Mhatre","email":"[email protected]","apiVersion":"1.1+"}
> curl -H "API-Version: 1.9" http://localhost:8085/accounts/42
{"id":"42","name":"Ankur Mhatre","email":"[email protected]","apiVersion":"1.1+"}
The practical takeaway: addSupportedVersions(...) (or a custom setSupportedVersionPredicate(...)) is your actual client-facing contract — it’s an allow-list, not a hint. A 1.1+ baseline mapping tells Spring which method serves a version once that version is accepted; it does not widen what gets accepted in the first place. If you want to genuinely support “any 1.x version,” you need a supported-version predicate that expresses that range, not just a baseline mapping.

Deprecation headers
Once you’re running multiple versions, telling clients a version is going away is half the job. Framework 7 has a built-in StandardApiVersionDeprecationHandler that sets the Deprecation and Sunset headers (RFC 9745 and RFC 8594) plus a Link header, purely from configuration — no controller code involved:
StandardApiVersionDeprecationHandler deprecationHandler = new StandardApiVersionDeprecationHandler();
deprecationHandler.configureVersion("1")
.setDeprecationDate(ZonedDateTime.parse("2026-01-01T00:00:00Z"))
.setDeprecationLink(URI.create("https://ankurm.com/spring-framework-7-api-versioning-guide/"))
.setSunsetDate(ZonedDateTime.parse("2026-12-31T00:00:00Z"))
.setSunsetLink(URI.create("https://ankurm.com/spring-framework-7-api-versioning-guide/"));
configurer.setDeprecationHandler(deprecationHandler);
Requesting version 1 now returns these headers alongside the usual body, captured directly from the running app:
> curl -i -H "API-Version: 1" http://localhost:8085/accounts/42
Deprecation: @1767225600
Sunset: Thu, 31 Dec 2026 00:00:00 GMT
Link: <https://ankurm.com/spring-framework-7-api-versioning-guide/>; rel="deprecation"; type="text/html",
<https://ankurm.com/spring-framework-7-api-versioning-guide/>; rel="sunset"; type="text/html"
The Deprecation value is an epoch timestamp per RFC 9745 (@1767225600 is midnight UTC on 1 Jan 2026, when I set the deprecation date) — that’s the spec’s format, not a bug. Any HTTP-aware client library, API gateway, or monitoring tool that understands these two RFCs picks this up automatically, with zero custom header-parsing code on either side.
Client-side versioning
Everything so far is server-side. If your own service calls another versioned Spring API, RestClient and WebClient take an ApiVersionInserter so you configure the version mechanism once and just pass a version value per call:
RestClient client = RestClient.builder()
.baseUrl("http://localhost:8085")
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version"))
.build();
String v1 = client.get().uri("/accounts/{id}", 42)
.apiVersion("1")
.retrieve()
.body(String.class);
System.out.println("Requested version 1 -> " + v1);
Run against the same server, this is the real output:
Requested version 1 -> {"id":"42","name":"Ankur Mhatre","apiVersion":"1"}
Requested version 2 -> {"accountId":"42","displayName":"Ankur Mhatre","contact":{"email":"[email protected]"},"apiVersion":"2"}
Requesting version "1.5" from this same client threw a real HttpClientErrorException.BadRequest for exactly the reason covered in the supported-versions section above — the client-side inserter doesn’t change what the server will accept, it only changes how the version gets attached to the request.
HTTP interface clients
If you use Spring’s declarative HTTP interfaces (an interface plus @HttpExchange, no implementation code), the same version attribute is available there too:
@HttpExchange("/accounts")
public interface AccountService {
@GetExchange(url = "/{id}", version = "1.1+")
Account getAccount(@PathVariable int id);
}
The proxy Spring generates for this interface reads the version attribute the same way @GetMapping does on the server side, and inserts it using whatever ApiVersionInserter the backing RestClient or WebClient is configured with.
Testing versioned APIs
Framework 7 also introduces RestTestClient (a non-reactive sibling of WebTestClient, so you get the same fluent assertions without pulling in the reactive stack), and both test clients support versioning the same way RestClient/WebClient do — configure an ApiVersionInserter once, then set a version per request.
- Standalone setup (no application context) — you must provide an
ApiVersionStrategyyourself, since there’s noWebConfigbeing loaded. - ApplicationContext setup — the context already has your
configureApiVersioning(...)config, so there’s nothing extra to wire up. - Live server setup — the server runs independently and is configured exactly as it would be in production.
MockMvc supports the same idea without a test client at all: configure an ApiVersionInserter to initialize the MockHttpServletRequest, plus an ApiVersionStrategy if you’re using the standalone setup. In practice, if your tests already use @SpringBootTest with a full context, versioning “just works” in your existing MockMvc/RestTestClient tests with no extra wiring — it inherits the same WebConfig your app uses at runtime.
The migration checklist
- Decide the resolution mechanism first — header, query param, media type, or path segment — and configure it once via
ApiVersionConfigurer. Changing it later is a breaking change for every existing client. - Set
setVersionRequired(false)deliberately, and pair it with an explicitsetDefaultVersion(...)if you don’t want unversioned requests silently landing on your newest response shape. - Use fixed versions (
"2") for handlers with a real shape change, and baseline versions ("1.1+") for the common case where nothing changed and you just want the handler to keep serving future versions. - Treat
addSupportedVersions(...)(or a custom supported-version predicate) as your actual contract with clients — a baseline mapping does not widen it. Test the exact version numbers you intend to support, not just the ones with dedicated handlers. - Configure a
StandardApiVersionDeprecationHandleras soon as you deprecate a version, with real dates — the RFC 9745/8594 headers are picked up by well-behaved HTTP tooling automatically. - For downstream calls to other versioned Spring APIs, configure
ApiVersionInserteronce on your sharedRestClient/WebClientbuilder rather than hand-setting headers per call. - If you use
@HttpExchangeinterfaces, addversionthere too so declarative clients stay in sync with the rest of your versioning scheme. - Re-run your
MockMvc/RestTestClienttest suite after adding versioning — an@SpringBootTestcontext picks up yourWebConfigautomatically, but standalone tests need their ownApiVersionStrategy.
An AI prompt to plan your versioning rollout
Paste this into Claude, ChatGPT, Gemini, or Cursor with your Spring Boot project in context.
You are planning API versioning for a Spring Boot 4 / Spring Framework 7
application using its built-in versioning support (ApiVersionConfigurer,
the `version` attribute on @RequestMapping/@GetMapping/etc.).
Scan the project and report:
1. RESOLUTION MECHANISM: is there already a de facto version signal (a custom
header, a URL path segment, an Accept header parameter)? Recommend a
single ApiVersionConfigurer strategy that matches existing client behavior
to avoid a breaking change during rollout.
2. CANDIDATE ENDPOINTS: list controller methods whose response shape has
changed across git history without any versioning -- these are the
endpoints most likely to need a version attribute retroactively.
3. BASELINE VS FIXED: for each candidate, recommend a fixed version (e.g. "2")
only if the shape genuinely changed, otherwise a baseline version (e.g.
"1.1+") to avoid handler duplication.
4. SUPPORTED VERSIONS: draft an addSupportedVersions(...) list or a custom
setSupportedVersionPredicate(...) that matches the intended client
contract. Flag if a baseline mapping exists without a matching supported
version being added -- this silently produces 400s for real clients.
5. DEPRECATION PLAN: for any version being phased out, draft a
StandardApiVersionDeprecationHandler configuration with realistic
deprecation/sunset dates.
6. CLIENT & TEST IMPACT: find internal RestClient/WebClient/@HttpExchange
usages calling these endpoints and note where an ApiVersionInserter needs
to be added; find MockMvc/RestTestClient tests that will need version
headers added to keep passing once versioning is enforced.
Report as a prioritized, file-referenced migration plan.
Frequently asked questions
Do I need Spring Boot 4 for API versioning, or just Spring Framework 7?
Just Framework 7.0+ for the core mechanism (ApiVersionConfigurer, the version attribute). Spring Boot 4 adds convenience properties on top — for example spring.mvc.apiversion.use.header=API-Version instead of a WebMvcConfigurer bean — but the underlying feature lives in Framework 7.
What happens if a request doesn’t include a version at all?
Depends on your config. With setVersionRequired(true) (the stricter option), missing versions are rejected. With false and no setDefaultVersion(...), I confirmed the request resolves to the highest supported version — not the lowest, which surprises people expecting old-client-safe defaults.
Does a baseline version like “1.1+” accept any version above 1.1?
No — this is the gotcha covered in the dedicated section above. It only serves versions that are already in your supported-version set. A version between two declared numbers (like 1.5, when only 1, 1.1, and 2 are supported) gets a 400, even though 1.1+ would happily serve it once 1.5 is added to the supported list.
Can I version by URL path instead of a header?
Yes, via configurer.usePathSegment(int), declaring the version position as a URI variable (e.g. /api/{version}/accounts/{id}). It’s usually configured once as a shared path prefix through Spring’s path-matching options rather than repeated per mapping.
Is this feature specific to Spring MVC, or does it work with WebFlux too?
Both. The configuration interface differs (WebMvcConfigurer vs the WebFlux config), and functional endpoints get a version(...) request predicate instead of an annotation attribute, but the underlying versioning model — resolution, parsing, baselines, deprecation — is shared.
See also
- Spring Framework 6 to 7 Migration Guide
- Spring Boot 3 to 4 Migration Guide
- Spring Boot 4 HTTP Service Clients
- Micrometer to OpenTelemetry: The Spring Boot 4 Observability Guide
- 10 AI Prompts for Spring Boot Development
Conclusion
Spring Framework 7’s API versioning removes the excuse to skip versioning, but it doesn’t remove the need to think about your actual contract with clients. The mechanics are simple once you’ve seen them run: a version attribute for routing, an ApiVersionConfigurer for resolution, a deprecation handler for RFC-compliant headers, and matching client-side support so your own services don’t have to hand-roll version headers either. The one thing worth internalizing before you ship this: a baseline (1.1+) handler and your supported-version list are two different things, and only the second one is your real contract. Get that right, and this is genuinely less code than whatever versioning scheme you were duct-taping together before.