You want to make a breaking change to your API, but you don’t want to break existing clients. In any project under active development, this situation comes up almost without fail.

There are three main approaches to REST API versioning: URI path, custom request header, and content negotiation via the Accept header. None of them is “the right answer”; each comes with its own trade-offs. In this article, we’ll walk through the characteristics of each approach and how to choose between them, with implementation code for Spring Boot along the way.

For basic CRUD implementation, see Spring Boot REST API CRUD Tutorial, and for exception handling, see Exception Handling in Spring Boot REST APIs.

Approach 1: URI Path Versioning

This is the approach you’ll see most often. The version number is embedded in the path, as in /api/v1/users or /api/v2/users.

@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {

    @GetMapping("/{id}")
    public UserV1Response getUser(@PathVariable Long id) {
        // v1のレスポンス形式
        return new UserV1Response(id, "山田太郎");
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {

    @GetMapping("/{id}")
    public UserV2Response getUser(@PathVariable Long id) {
        // v2ではfullNameをfirstName/lastNameに分割
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is simplicity. You can type the URL directly into a browser’s address bar, and testing with curl is easy. Proxy and CDN caching work well, and the version is immediately visible in logs.

The disadvantage is that the version leaks into the URI. REST principles hold that the same resource should ideally have the same URI, so having /users/1 and /api/v2/users/1 point to the same resource is, strictly speaking, a little awkward. That said, in practice this is accepted in the vast majority of cases.

Approach 2: Custom Request Header Versioning

This approach specifies the version with a custom header such as X-API-Version: 2. You can route requests using the headers attribute of @RequestMapping.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is that the URI stays unchanged at /api/users/{id}. This is easy to work with in environments where clients have full control over headers, such as service-to-service communication or internal APIs.

On the other hand, sending requests directly from a browser is difficult, so you’ll need curl or Postman for testing. When using a custom header, don’t forget to add it to Access-Control-Allow-Headers for CORS preflight requests (OPTIONS). See also how to configure CORS.

Approach 3: Accept Header / Content Negotiation

This is the approach most faithful to the HTTP specification. The version is expressed through a vendor media type such as Accept: application/vnd.myapp.v2+json.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v1+json"
    )
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v2+json"
    )
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is that it aligns with HTTP’s design philosophy and lets you declare the media type and version at the same time. However, clients must specify the Accept header precisely, so the implementation cost is higher. Displaying it in Swagger UI also requires some extra work.

Comparing the Three Approaches

AspectURI PathCustom HeaderAccept Header
Readability
Caching
Client cost
REST compliance
Browser testing
Swagger UI support

Which Approach Should You Choose?

Here’s a summary of the decision criteria when you’re unsure.

  • Public APIs or APIs accessed from browsers → URI path is the safe choice. Readability and ease of testing take top priority.
  • Primarily internal APIs or machine-to-machine communication → Custom headers are also an option. You can keep URIs simple while controlling the version via headers.
  • When you want to follow the HTTP specification strictly → The Accept header approach is ideal, but give careful consideration to the implementation burden on the client side.

Honestly, for most projects, the URI path approach is the practical first choice. Its operational simplicity usually outweighs its drawbacks.

Displaying Multiple Versions in Swagger UI with SpringDoc OpenAPI

If you’ve already completed the basic setup from Getting Started with SpringDoc OpenAPI, you can use GroupedOpenApi to display v1 and v2 as separate groups.

@Configuration
public class OpenApiConfig {

    @Bean
    public GroupedOpenApi v1Api() {
        return GroupedOpenApi.builder()
            .group("v1")
            .pathsToMatch("/api/v1/**")
            .build();
    }

    @Bean
    public GroupedOpenApi v2Api() {
        return GroupedOpenApi.builder()
            .group("v2")
            .pathsToMatch("/api/v2/**")
            .build();
    }
}

With this in place, you can switch between v1 and v2 from the dropdown in Swagger UI. For the custom header and Accept header approaches, you can use addOperationCustomizer to add the information to the documentation, but the URI path approach integrates most simply.

Deprecation Notices for Retired Versions

When you plan to retire v1, it’s considerate to notify clients via response headers. You can use the Sunset header defined in RFC 8594.

@Component
public class DeprecationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        chain.doFilter(request, response);

        if (request.getRequestURI().startsWith("/api/v1/")) {
            response.setHeader("Deprecation", "true");
            response.setHeader("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT");
            response.setHeader("Link", "</api/v2/>; rel=\"successor-version\"");
        }
    }
}

This automatically attaches the planned retirement date to every request to v1. It gives client teams plenty of lead time to migrate, so ideally you’d put this in place six months to a year before the retirement.

Implementation Pitfalls

Cases where AmbiguousMapping occurs

If multiple Controllers are mapped to the same path, an exception is thrown at startup. Even with the URI path approach, make sure the path definitions in @RequestMapping don’t overlap.

CORS and custom headers

If you adopt the custom header approach, preflight requests will fail unless you add X-API-Version to the CORS allowedHeaders.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedHeaders(List.of("Content-Type", "X-API-Version"));
    // ...
}

Summary

There is no single “correct” versioning approach. URI path is simple and widely accepted, custom headers suit internal APIs where you want to keep URIs clean, and the Accept header is an option when faithfulness to the specification matters most.

If you confirm your team’s client types and operational policy before deciding on an approach, you’re less likely to end up wanting to change it later. When in doubt, starting with URI path and building out Deprecation notices as needed is, I think, the practical way to go.