DEV Community

Cover image for The Version That Broke Everything
Prem Kapadne
Prem Kapadne

Posted on

The Version That Broke Everything

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
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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.items and response.restaurant as 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}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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, v3 methods 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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)