A deep dive into API versioning — what goes wrong without it, the old way developers survived it, and why Spring 7's new approach changes everything.
⏱ 12 min read · 🎯 Intermediate · 🛠 Spring Boot 3.x / Spring 7+
It was a Tuesday. Nothing seemed wrong.
Imagine a dev team at a fast-growing startup. They have a food delivery platform — FoodRush — serving over a million mobile users, 5,000 restaurant partners, and 50 third-party business integrations. The backend is humming. The original restaurant menu API has been rock-solid for two years:
GET https://api.foodrush.com/restaurants/menu/{restaurant_id}
// Response looked like this — clean, simple
{
"restaurant": "Pizza Palace",
"items": [
{
"id": 101,
"name": "Margherita Pizza",
"price": 12.99,
"available": true
}
]
}
Then the product team had "improvements." They wanted richer data — restaurant ratings, nested pricing with currency info, prep times. A well-meaning developer updated the API. One deployment. No flags. No versioning. The new response looked like this:
💥 BREAKING CHANGE
{
"restaurant": { // was a string, now an object
"name": "Pizza Palace",
"id": "rest_001",
"rating": 4.5
},
"menu_items": [ // "items" renamed to "menu_items"
{
"id": 101,
"name": "Margherita Pizza",
"pricing": { // flat "price" replaced with nested object
"amount": 12.99,
"currency": "USD"
},
"availability": { // boolean → object
"in_stock": true,
"prep_time": 20
}
}
]
}
The next morning: Slack explodes. The iOS app is crashing on launch. The restaurant partner dashboard is showing blank menus. Three enterprise integrations are throwing 500 errors. Support tickets are flooding in. The on-call engineer stares at the screen. The API "works" — it returns 200. But every single client is broken, because they were all parsing
response.itemsandresponse.restaurantas a string. Now those fields don't exist in the old shape anymore.
Four breaking changes. Zero warning. Every client — mobile app, website, restaurant apps, delivery partner apps, third-party integrations — broke simultaneously. What should have been a simple product improvement turned into a company-wide incident. And it was entirely preventable.
⚡ The Real Problem
Evolving an API is inevitable. Products grow, requirements change, better designs emerge. The sin isn't changing your API — it's changing it without giving your clients a safe path through the transition.
What API versioning actually means
API versioning is not complicated in concept. It's the practice of allowing multiple versions of your API to exist simultaneously, so old clients keep working while new clients adopt the improved contract. Think of it like a software release: you don't delete version 1 the moment version 2 ships. Both live side by side until every client has migrated.
In the FoodRush disaster, the fix would have been simple. Instead of silently replacing the response shape, they should have done this:
// v1 — still returns the original format. Zero disruption.
GET https://api.foodrush.com/v1/restaurants/menu/{restaurant_id}
// v2 — new format for clients ready to upgrade
GET https://api.foodrush.com/v2/restaurants/menu/{restaurant_id}
With this in place, the migration becomes gradual and controlled:
- Week 1 — Launch v2: v1 still works with zero disruption. v2 is available with the new format. Email sent to all developer partners about v2.
- Month 1 — Mobile migration: New app version uses v2. Old app versions still work on v1. Users update at their own pace.
- Month 2 — Partner migration: Partners test v2 in staging, switch when ready. No forced deadlines.
- Month 6 — Deprecation notice: "v1 will be deprecated in 6 months." 99% of traffic on v2. Stragglers helped to migrate.
- Year 1 — Sunset v1: Turn off v1 with confidence. All clients migrated. Zero business disruption.
That's the power of versioning: you control the pace of change. Clients don't get surprised. Your API evolves without breaking the trust of people building on top of it.
The four ways to version an API
Before we look at code, it's worth understanding the conceptual approaches. Every versioning strategy is just answering one question differently: how does the client tell the server which version it wants?
| Approach | Example |
|---|---|
| 🛣️ URI Path | /api/v1/users |
| 🔍 Query Param | /api/users?version=1 |
| 📋 Header | X-API-VERSION: 1 |
| 🎭 Media Type | Accept: application/vnd.myapp.v1+json |
That last one — Media Type Versioning — is the most REST-compliant but also the least understood. Quick explainer: MIME stands for Multipurpose Internet Mail Extensions. It's the standard way to describe what type of data is being sent. The string application/vnd.myapp.v1+json breaks down as:
| Part | Meaning |
|---|---|
application |
Top-level MIME type — general category of data |
vnd |
Vendor-specific — this is a custom type, not a global standard |
myapp |
Your application or company name |
v1 |
The API version you're requesting |
+json |
The data format — return it as JSON |
The vnd prefix signals that this media type belongs to you — it's specific to your application, not a global internet standard. GitHub, for example, uses application/vnd.github.v3+json. The URL stays clean and stable; only the representation changes.
The old way — and why it hurts
Before Spring 7, developers had to wire up versioning manually inside their controllers. The mapping annotations — @GetMapping, @PostMapping etc. — carried the full versioning burden. Let's look at all four approaches in the legacy style:
1. URI path versioning (legacy)
@GetMapping({"", "/", "/v1"})
public String defaultPathVersion() {
return "Response from API 1.0.0";
}
@GetMapping("/v2")
public String pathV2Version() {
return "Response from API 2.0.0";
}
2. Request parameter versioning (legacy)
@GetMapping(params = "version=1")
public String defaultReqParamVersion() {
return "Response from API 1.0.0";
}
@GetMapping(params = "version=2")
public String v2ReqParamVersion() {
return "Response from API 2.0.0";
}
3. Request header versioning (legacy)
@GetMapping(headers = "X-API-VERSION=1")
public String defaultReqHeaderVersion() {
return "Response from API 1.0.0";
}
@GetMapping(headers = "X-API-VERSION=2")
public String v2ReqHeaderVersion() {
return "Response from API 2.0.0";
}
4. Media type versioning (legacy)
@GetMapping(produces = "application/vnd.eazyapp.v1+json")
public String defaultMediaTypeVersion() {
return "Response from API 1.0.0";
}
@GetMapping(produces = "application/vnd.eazyapp.v2+json")
public String v2MediaTypeVersion() {
return "Response from API 2.0.0";
}
At first glance these look fine. But imagine you have 50 endpoints across 10 controllers. Now imagine you need to add v3. Here's what the pain feels like:
- 😵 Version logic lives in the wrong place. Your controller's job is to handle business logic — not to know which HTTP header a client passes. Versioning concerns bleed into every single method.
- 🔁 Maintenance overhead explodes. Adding a new version means touching every controller, every method. In a large codebase that's hundreds of changes — all error-prone, all manual.
- 🤯 No central source of truth. Want to know which versions your API supports? You have to grep through every annotation in every controller. There's no single configuration file that tells you.
- 📦 Version sprawl becomes unmanageable. Three versions in, your controllers look like an archaeological dig — layers of
v1,v2,v3methods stacked on top of each other with no clear separation. - 🔀 Switching strategies is painful. If you started with URI versioning and later decided header versioning is cleaner, you'd need to rewrite annotations across your entire codebase.
⚠️ The hidden cost
The legacy approach technically works. But it doesn't scale. Every version you add multiplies the maintenance burden. By version 4, your codebase has become a versioning museum rather than a product.
The Spring 7 way — one config to rule them all
Spring 7 introduced a first-class API versioning system that flips the entire model. Instead of embedding version logic inside annotations scattered across your controllers, you declare your versioning strategy once in a central configuration class. The controllers themselves stay clean — they only use a simple version attribute on their mapping annotations.
The core idea: your controllers declare which version they serve. A separate configuration class declares how versioning works (URI, header, query param, media type). This separation of concerns is what makes it powerful.
1. URI path versioning (Spring 7+)
// Controller — declares versions, knows nothing about URL structure
@RestController
@RequestMapping("/api/versions/{v}")
public class VersionController {
@GetMapping(version = "1.0")
public String defaultVersion() {
return "Response from API 1.0";
}
@GetMapping(version = "2.0+") // "+" means 2.0 and above
public String v2Version() {
return "Response from API 2.0";
}
}
// Central config — the only place versioning strategy lives
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.usePathSegment(2) // pick segment 2 of the URL path for version
.addSupportedVersions("1.0", "2.0", "3.0");
}
}
2. Request parameter versioning (Spring 7+)
// Controller stays identical — same version attributes
@RestController
@RequestMapping("/api/versions")
public class VersionController {
@GetMapping(version = "1.0")
public String defaultVersion() { ... }
@GetMapping(version = "2.0+")
public String v2Version() { ... }
}
// Only the config changes — the controller doesn't need to know
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useQueryParam("version") // ?version=1.0 in the URL
.addSupportedVersions("1.0", "2.0", "3.0")
.setDefaultVersion("1.0"); // fallback when no version specified
}
}
3. Request header versioning (Spring 7+)
// Controller — unchanged. Same version attributes, always.
@RestController
@RequestMapping("/api/versions")
public class VersionController {
@GetMapping(version = "1.0")
public String defaultVersion() { ... }
@GetMapping(version = "2.0+")
public String v2Version() { ... }
}
// Config: clients now pass X-API-VERSION: 1.0 in request headers
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useRequestHeader("X-API-VERSION")
.addSupportedVersions("1.0", "2.0", "3.0")
.setDefaultVersion("1.0");
}
}
4. Media type versioning (Spring 7+)
// Controller — identical to all the above. This is the beauty of it.
@RestController
@RequestMapping("/api/versions")
public class VersionController {
@GetMapping(version = "1.0")
public String defaultVersion() { ... }
@GetMapping(version = "2.0+")
public String v2Version() { ... }
}
// Client sends: Accept: application/vnd.eazyapp+json;v=1.0
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useMediaTypeParameter(
MediaType.parseMediaType("application/vnd.eazyapp+json"),
"v"
)
.addSupportedVersions("1.0", "2.0", "3.0")
.setDefaultVersion("1.0");
}
}
Notice something remarkable: the controller code is identical across all four strategies. Only the WebConfig changes. Want to switch from URI versioning to header versioning across your entire application? Change one method in one file. Done.
✅ What Spring 7 gives you
One config file = one source of truth for your entire versioning strategy. Your controllers are clean. Switching strategies costs you exactly one edit. Adding a new supported version is a single method call. This is what "separation of concerns" looks like in practice.
Legacy vs Spring 7 — side by side
| Concern | Legacy approach | Spring 7+ |
|---|---|---|
| Version declaration | Inside every @GetMapping annotation |
Single version attribute per method |
| Strategy config | Scattered | Centralised |
| Switch strategy | Rewrite every controller annotation | Change one line in WebConfig
|
| Add new version | Manually add method per endpoint | .addSupportedVersions("3.0") |
| Default version | Manual fallback logic | .setDefaultVersion() |
| Version range (2.0+) | Not possible | Supported natively |
| Supported in | All Spring Boot versions | Spring 7+ / Spring Boot 4+ |
Which strategy should you choose?
| Strategy | Best for | Watch out for |
|---|---|---|
URI Path /v1/
|
Public APIs, easy testing in browser, most widely understood | URL proliferation as versions grow |
Query Param ?version=1
|
Internal APIs, quick prototyping | Cache complications — same URL, different responses |
Header X-API-VERSION
|
Clean URLs, microservice-to-microservice calls | Harder to test without proper tools like Postman |
| Media Type | Strict REST compliance, enterprise APIs | Steeper learning curve, verbose Accept headers |
For most projects — especially public-facing APIs and portfolio projects — URI path versioning is the pragmatic choice. It's explicit, easy to test, simple to document, and universally understood by developers of all experience levels. Move to header or media type versioning only when clean URLs and strict REST purity become actual requirements.
💡 Interview insight
In interviews, if asked about API versioning, most candidates mention URI versioning and stop there. Stand out by explaining all four strategies, their trade-offs, and then discussing the Spring 7 centralised configuration model. It shows you understand not just what to do but why the new approach is a better design.
The takeaway
API versioning is one of those things that feels optional until the day it isn't. The FoodRush disaster was fictional, but the scenario plays out in real companies every year. A well-intentioned improvement, deployed without versioning, breaks clients silently and erodes the trust of everyone building on top of your API.
The legacy Spring approach gave developers the tools to version APIs but left the housekeeping entirely up to them — scattered across annotations, mixed into business logic, painful to change. Spring 7's configureApiVersioning method finally gives versioning a proper home: one place, one strategy, one source of truth.
Whatever strategy you pick — URI, header, query param, or media type — the discipline of versioning your API from day one is the real lesson. Change is inevitable. How gracefully your clients survive that change is entirely in your hands.
Written as part of a Spring Boot deep dive. Code examples use Spring 7+ / Spring Boot 4.x with the new ApiVersionConfigurer API. Legacy examples are compatible with Spring Boot 2.x and 3.x.
Top comments (0)