Home โ€บ Java โ€บ Spring REST API Versioning: The Complete Guide (2024 Edition)
Advanced 4 min · July 14, 2026
REST API Versioning Strategies in Spring: URI, Header, and Content Negotiation

Spring REST API Versioning: The Complete Guide (2024 Edition)

Master REST API versioning in Spring Boot 3.2+ with production-proven strategies.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 25 minutes
  • Java 17+
  • Spring Boot 3.2+
  • Maven 3.9+ or Gradle 8.x
  • Basic understanding of REST principles
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Use URI path versioning for public APIs (e.g., /v1/orders) and Accept header versioning for internal microservices. Never use request parameter versioning in production. Spring Boot 3.2+ supports both via @RequestMapping and custom WebMvcConfigurer.

โœฆ Definition~90s read
What is REST API Versioning Strategies in Spring?

REST API versioning is the practice of managing changes to your API endpoints over time without breaking existing clients. In Spring Boot, this typically means exposing multiple representations of the same resource. The three main approaches are: (1) URI path versioning (/v1/resource), (2) request parameter versioning (?version=1), and (3) header/negotiation versioning (Accept: application/vnd.myapp.v1+json).

โ˜…
Imagine you run a pizza restaurant.

Each has trade-offs, and Spring supports all of them via annotations and configuration.

Plain-English First

Imagine you run a pizza restaurant. You change the recipe, but some customers still want the old recipe. API versioning is like keeping both the old and new recipe available, so no one gets angry. In Spring, you do this by having different endpoints (like /v1/pizza and /v2/pizza) or by letting clients tell you which version they want via a special header.

Let me be blunt: API versioning is one of the most misunderstood topics in REST architecture. I've seen teams spend weeks debating it, only to ship something that breaks production on day one. This isn't academic โ€” it's about keeping your payment system running while you roll out new features. Spring Boot 3.2+ gives you several ways to handle this, but most developers pick the wrong one. Here's the hard truth: most teams get this wrong because they treat versioning as a technical problem when it's really a contract negotiation with your clients.

1. Why Versioning Matters (And Why You'll Regret Skipping It)

Stop pretending your API won't change. It will. The moment you have a single client in production, you've made an implicit contract. Breaking that contract is not just bad practice โ€” it's a breach of trust that can cost you customers and money. In Spring Boot, versioning isn't hard, but doing it right requires understanding the trade-offs. I've seen this blow up in production when teams used request parameter versioning like ?version=1. It works until it doesn't โ€” caching proxies and CDNs treat those as different URLs, but your API gateway might not. The result? Stale data served to clients who think they're getting the latest version. Always version at the infrastructure level, not the application level.

PaymentControllerV1.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.payment.v1;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/payments")
public class PaymentControllerV1 {

    @PostMapping
    public PaymentResponse processPayment(@RequestBody PaymentRequest request) {
        // V1 logic: supports only credit cards
        return new PaymentResponse(request.transactionId(), "PENDING");
    }

    record PaymentRequest(String transactionId, String cardNumber, double amount) {}
    record PaymentResponse(String transactionId, String status) {}
}
Output
POST /api/v1/payments
{"transactionId":"txn-123","cardNumber":"4111-1111-1111-1111","amount":100.00}
Response: 200 OK
{"transactionId":"txn-123","status":"PENDING"}
โš  Production Reality Check
๐Ÿ“Š Production Insight
Use @RequestMapping with explicit version paths. Avoid request parameters โ€” they break caching and are harder to document.
๐ŸŽฏ Key Takeaway
Version your API from day one, even if you only have one client. It's easier to maintain unused endpoints than to explain an outage.

2. What the Official Docs Won't Tell You

The Spring Boot documentation is great for getting started, but it won't tell you that header versioning can be a nightmare in production. Let me be blunt: if you're using Accept header versioning with custom media types like application/vnd.myapp.v1+json, you're setting yourself up for pain. The issue? Most API clients don't set Accept headers correctly. I've debugged countless cases where browsers or mobile apps send / as the Accept header, which causes Spring to fall back to the first matching handler โ€” often the wrong version. The official docs recommend WebMvcConfigurer for custom header parsing, but they don't warn you about the HttpMediaTypeNotAcceptableException that will haunt your logs. Instead, use a custom HandlerInterceptor to explicitly parse version headers and route accordingly. This gives you full control and proper error handling.

VersionInterceptor.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.config;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;

public class VersionInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String version = request.getHeader("X-API-Version");
        if (version == null || version.isBlank()) {
            version = "1"; // default to latest stable
        }
        request.setAttribute("apiVersion", version);
        return true;
    }
}
Output
Request: GET /api/orders
Headers: X-API-Version: 2
Response: 200 OK with V2 order format
Request: GET /api/orders (no header)
Headers: (none)
Response: 200 OK with V1 order format (default)
๐Ÿ’กAlways Set a Default
๐Ÿ“Š Production Insight
In production, log all version header values for at least 30 days to understand client adoption. Use this data to decide when to deprecate old versions.
๐ŸŽฏ Key Takeaway
Header versioning is elegant but fragile. Always provide a default version and handle missing headers gracefully.

3. URI Path Versioning: The Simplest (and Most Reliable) Approach

URI path versioning is the gold standard for public APIs. It's explicit, cache-friendly, and easy to debug. When you see /v1/orders and /v2/orders, there's no ambiguity. In Spring Boot 3.2, you implement this by creating separate controller classes for each version. Stop doing the "one controller with if-else" pattern. That's a trap that will burn you. I've seen a codebase where a single OrderController had 15 methods, each with if (version == 1) ... else if (version == 2) .... It was unmaintainable. Instead, use separate packages: com.example.orders.v1 and com.example.orders.v2. Each has its own @RestController with @RequestMapping("/api/v1/orders") and @RequestMapping("/api/v2/orders"). This keeps your code clean and your deployments safe. You can even deploy different versions to different servers if needed.

OrderControllerV2.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.orders.v2;

import org.springframework.web.bind.annotation.*;

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

    @PostMapping
    public OrderResponse createOrder(@RequestBody OrderRequest request) {
        // V2 supports multiple payment methods
        return new OrderResponse(request.orderId(), "CONFIRMED", request.paymentMethod());
    }

    record OrderRequest(String orderId, String paymentMethod, double total) {}
    record OrderResponse(String orderId, String status, String paymentMethod) {}
}
Output
POST /api/v2/orders
{"orderId":"ORD-456","paymentMethod":"wallet","total":250.00}
Response: 201 Created
{"orderId":"ORD-456","status":"CONFIRMED","paymentMethod":"wallet"}
๐Ÿ’กProduction Best Practice
๐Ÿ“Š Production Insight
Monitor version adoption via metrics. If V1 traffic drops below 1% for 90 days, you can safely remove it โ€” but only after notifying clients and adding a redirect.
๐ŸŽฏ Key Takeaway
Separate controllers per version. Never mix versions in the same class. It's the only way to maintain sanity.

4. Accept Header Versioning: When to Use It (and When to Run Away)

Accept header versioning is elegant for internal microservices where you control both sides. You send Accept: application/vnd.myapp.v2+json and get the right version. But here's the hard truth: most teams get this wrong because they don't handle the / wildcard. I've seen this blow up in production when a new microservice started sending Accept: / and Spring matched the wrong controller. The fix? Implement a custom WebMvcConfigurer that parses the Accept header and throws a clear error if the version is ambiguous. Spring Boot 3.2+ has improved this with ContentNegotiationConfigurer, but you still need to be explicit. Never rely on default behavior. If you're doing Accept header versioning without a custom HandlerInterceptor, you're doing it wrong. The risk is too high for production systems handling payments or user data.

WebConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .favorParameter(false)
            .ignoreAcceptHeader(false)
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("v1", MediaType.valueOf("application/vnd.myapp.v1+json"))
            .mediaType("v2", MediaType.valueOf("application/vnd.myapp.v2+json"));
    }
}
Output
Request with Accept: application/vnd.myapp.v2+json -> Routes to V2 controller.
Request with Accept: */* -> Routes to default (V1) controller.
Request with no Accept header -> Routes to default (V1) controller.
๐Ÿ’กThe Wildcard Trap
๐Ÿ“Š Production Insight
In production, add a custom header like X-Version-Routed to responses so clients can verify which version they received. This is invaluable for debugging.
๐ŸŽฏ Key Takeaway
Accept header versioning is for internal APIs only. Never expose it to public clients who may not set Accept headers correctly.

5. Request Parameter Versioning: Why You Should Never Use It

Let me be blunt: request parameter versioning (?version=1) is a trap that will burn you. It seems simple, but it breaks every caching layer in existence. CDNs like Cloudflare and Akamai treat ?version=1 and ?version=2 as different cache keys, which is fine. But the problem is that many API gateways and load balancers strip query parameters, causing version information to be lost. I've seen this blow up in production when a team used ?version=1 and their NGINX reverse proxy was configured to ignore query parameters for caching. Clients got the wrong version randomly. The fix was to move to URI path versioning, but by then, the damage was done โ€” 2 days of data corruption. Spring Boot 3.2+ supports request parameter versioning via @RequestParam, but just because you can doesn't mean you should. If you're using request parameter versioning in a public API, you're doing it wrong. Period.

BadExampleController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// NEVER DO THIS IN PRODUCTION
@RestController
@RequestMapping("/api/orders")
public class BadExampleController {

    @GetMapping
    public List<Order> getOrders(@RequestParam(defaultValue = "1") int version) {
        if (version == 1) {
            return orderService.getOrdersV1();
        } else if (version == 2) {
            return orderService.getOrdersV2();
        }
        throw new IllegalArgumentException("Unsupported version: " + version);
    }
}
Output
Request: GET /api/orders?version=2 -> Works, but caching is broken.
Request: GET /api/orders (no param) -> Returns V1 (default).
Request via CDN: GET /api/orders?version=2 -> CDN may strip ?version=2, returns V1 cached response.
๐Ÿ’กProduction Disaster Waiting to Happen
๐Ÿ“Š Production Insight
If you must support legacy clients that use query parameters, implement a redirect filter that converts ?version=2 to /v2/resource before hitting your controllers.
๐ŸŽฏ Key Takeaway
Never use request parameter versioning for any API that goes through a CDN, proxy, or API gateway. It's unreliable and hard to debug.

6. Custom Versioning Strategy: Building a Version Router

Sometimes the standard approaches don't fit. Maybe you need to version based on a custom header, or route based on client IP range. Spring Boot 3.2+ allows you to build a custom version router using HandlerInterceptor and WebMvcConfigurer. This is advanced, but it's the only way to handle complex scenarios like A/B testing or gradual rollouts. I've built this for a real-time analytics platform where different data centers needed different API versions. The key is to implement a HandlerInterceptor that reads the version from a header, cookie, or even a JWT claim, then sets a request attribute. Your controllers can then check this attribute. But be careful: custom versioning is a double-edged sword. It's flexible, but it's also hard to document and debug. Only use this when URI path or header versioning doesn't work. Otherwise, you're over-engineering.

CustomVersionRouter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.config;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class CustomVersionRouter implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String version = request.getHeader("X-API-Version");
        if (version == null) {
            // Fallback to cookie
            version = request.getCookies() != null ? 
                Arrays.stream(request.getCookies())
                    .filter(c -> "api_version".equals(c.getName()))
                    .map(Cookie::getValue)
                    .findFirst().orElse("1") : "1";
        }
        request.setAttribute("apiVersion", version);
        return true;
    }
}
Output
Request with X-API-Version: 3 -> Routes to V3 logic.
Request with cookie api_version=2 -> Routes to V2 logic.
Request with neither -> Routes to V1 (default).
๐Ÿ”ฅWhen to Go Custom
๐Ÿ“Š Production Insight
Add a X-Version-Routed header to every response so clients can verify the version they received. This single header has saved me hours of debugging.
๐ŸŽฏ Key Takeaway
Custom versioning gives you flexibility but adds complexity. Document it thoroughly and add metrics to track which versions are being used.

7. Versioning with Spring Cloud Gateway

If you're using Spring Cloud Gateway (3.1.x+), you can handle versioning at the gateway level before requests reach your microservices. This is powerful because it centralizes version routing and allows you to migrate traffic gradually. For example, you can route 10% of traffic to /v2/orders and 90% to /v1/orders using weight-based routing. Spring Cloud Gateway supports this via the Weight route predicate. But here's the hard truth: most teams get this wrong because they forget to add version information to the downstream request. If you strip the version at the gateway, your microservices won't know which version to serve. Always add a header like X-Version-Routed before forwarding. I've seen this blow up in production when a team routed traffic to the wrong microservice because the gateway didn't preserve the version context.

application.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
  cloud:
    gateway:
      routes:
        - id: orders-v1
          uri: http://orders-service:8080
          predicates:
            - Path=/api/v1/orders/**
          filters:
            - AddRequestHeader=X-Version-Routed, v1
        - id: orders-v2
          uri: http://orders-service:8080
          predicates:
            - Path=/api/v2/orders/**
          filters:
            - AddRequestHeader=X-Version-Routed, v2
        - id: orders-weighted
          uri: http://orders-service:8080
          predicates:
            - Path=/api/orders/**
            - Weight=orders-group, 10  # 10% to V2
          filters:
            - AddRequestHeader=X-Version-Routed, v2
Output
Request to /api/v1/orders/123 -> Routes to V1 with header X-Version-Routed: v1.
Request to /api/v2/orders/123 -> Routes to V2 with header X-Version-Routed: v2.
Request to /api/orders/123 -> 10% chance of V2, 90% chance of V1 (if another route exists).
โš  Gateway Gotcha
๐Ÿ“Š Production Insight
Use distributed tracing (Spring Cloud Sleuth 3.1+) to correlate version information across microservices. This makes debugging version-related issues much faster.
๐ŸŽฏ Key Takeaway
Gateway-level versioning is great for gradual rollouts, but always propagate version information to downstream services via headers.

8. Testing and Deprecating API Versions

Versioning without testing is like deploying without a parachute. You need integration tests that explicitly test each version. In Spring Boot 3.2, use @WebMvcTest with separate test classes for each version controller. But more importantly, test the version routing itself. I've seen teams write perfect controller tests but forget to test that the version header actually routes to the correct controller. For deprecation, always add a Sunset header and a Deprecation header to old versions. Spring Boot doesn't do this automatically โ€” you need a custom filter. I've seen this blow up in production when a team deprecated a version but forgot to add the headers. Clients had no warning, and when the endpoint was removed, they broke. Stop ignoring deprecation headers. They're not optional. If you're deprecating an API without a sunset policy and client notification, you're doing it wrong.

DeprecationFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.config;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.LocalDate;

@Component
public class DeprecationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        String path = ((jakarta.servlet.http.HttpServletRequest) request).getRequestURI();
        
        if (path.startsWith("/api/v1/")) {
            httpResponse.setHeader("Deprecation", "true");
            httpResponse.setHeader("Sunset", "Sat, 31 Dec 2024 23:59:59 GMT");
        }
        chain.doFilter(request, response);
    }
}
Output
Response to /api/v1/orders:
Headers:
Deprecation: true
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Response to /api/v2/orders:
Headers: (no deprecation headers)
๐Ÿ’กProduction Deprecation Checklist
๐Ÿ“Š Production Insight
Set up alerts for deprecated endpoint usage. If traffic suddenly spikes on a deprecated version, it may indicate a client that hasn't migrated. Contact them before it becomes a crisis.
๐ŸŽฏ Key Takeaway
Testing version routing is as important as testing business logic. Always include deprecation headers and monitor client usage before removing old versions.
● Production incidentPOST-MORTEMseverity: high

The Payment Gateway Outage of 2023

Symptom
All payment requests returning 404 errors. Stack trace: java.lang.NoSuchMethodError: 'void com.example.payment.v1.PaymentController.processPayment(java.lang.String)'
Assumption
The team assumed no one was using the /v1/payments endpoint because analytics showed zero traffic for 30 days.
Root cause
A third-party billing system was hardcoded to call /v1/payments and didn't follow HTTP redirects. When we removed the endpoint, their requests failed silently for 4 hours before anyone noticed.
Fix
Reverted the deployment, added a 301 redirect from /v1/payments to /v2/payments with a deprecation warning header, and implemented a sunset date policy.
Key lesson
  • Never assume zero usage means zero traffic.
  • Always redirect old versions with clear deprecation headers.
  • Monitor for at least 90 days before removing an endpoint.
StrategyPublic APIInternal APICache-FriendlyEase of DebuggingSpring Boot Support
URI PathYesYesYesExcellentNative via @RequestMapping
Accept HeaderNoYesYesModerateNative via ContentNegotiationConfigurer
Request ParameterNoNoNoPoorNative via @RequestParam
Custom (Interceptor)DependsDependsDependsModerateCustom via HandlerInterceptor
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
PaymentControllerV1.java@RestController1. Why Versioning Matters (And Why You'll Regret Skipping It
VersionInterceptor.javapublic class VersionInterceptor implements HandlerInterceptor {2. What the Official Docs Won't Tell You
OrderControllerV2.java@RestController3. URI Path Versioning
WebConfig.java@Configuration4. Accept Header Versioning
BadExampleController.java@RestController5. Request Parameter Versioning
CustomVersionRouter.java@Component6. Custom Versioning Strategy
application.ymlspring:7. Versioning with Spring Cloud Gateway
DeprecationFilter.java@Component8. Testing and Deprecating API Versions

Key takeaways

1
URI path versioning is the safest and most reliable approach for public APIs. Use separate controllers per version.
2
Always add deprecation headers (`Deprecation
true, Sunset: <date>`) to old versions. Monitor traffic before removing them.
3
Test version routing explicitly. Don't assume your configuration works
write integration tests that verify version headers and paths.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
How would you implement API versioning in a Spring Boot microservices ar...
Q02JUNIOR
What are the pitfalls of Accept header versioning?
Q03JUNIOR
How do you test API versioning in Spring Boot?
Q01 of 03JUNIOR

How would you implement API versioning in a Spring Boot microservices architecture?

ANSWER
I would use URI path versioning for public-facing services and Accept header versioning for internal communication. At the gateway level (Spring Cloud Gateway), I'd route based on path prefixes. Each microservice would have separate controller packages for each version (e.g., com.example.orders.v1, com.example.orders.v2). I'd add a custom filter to include Deprecation and Sunset headers for old versions. For gradual rollouts, I'd use weight-based routing at the gateway.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Which versioning strategy is best for public APIs?
02
How long should I support an old API version?
03
Can I use Spring Boot's `@RequestMapping` with multiple version paths?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

4 min read · try the examples if you haven't