Post

API Versioning Strategies in Spring Boot — URI, Header, and Content Negotiation

Compare API versioning strategies in Spring Boot: URI path versioning, custom header versioning, and content negotiation. Covers implementation patterns, deprecation workflow, backward compatibility, and when to use which approach.

API Versioning Strategies in Spring Boot — URI, Header, and Content Negotiation

The Problem — Breaking Changes

Your API is live. Clients depend on the response format. Now you need to:

  • Rename a field (userName → username)
  • Remove a deprecated field
  • Change a data type (String → Object)
  • Restructure the response entirely

Without versioning, every change risks breaking existing clients. You need a strategy that lets you evolve the API while giving clients time to migrate.

graph LR
    A[Client v1] --> B[API v1]
    C[Client v2] --> D[API v2]
    E[New Client] --> D
    B --> F[Same Backend]
    D --> F

diagram

The challenge isn’t implementing versions — it’s choosing the right strategy and managing the lifecycle.


Strategy 1: URI Path Versioning

The most visible and widely used approach. The version is part of the URL.

1
2
GET /api/v1/users/42
GET /api/v2/users/42

Implementation

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {

    @GetMapping("/{id}")
    public UserResponseV1 getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV1(user.getId(), user.getFirstName() + " " + user.getLastName(), user.getEmail());
    }
}

public record UserResponseV1(Long id, String userName, String email) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {

    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV2(
                user.getId(),
                new UserResponseV2.Name(user.getFirstName(), user.getLastName()),
                user.getEmail(),
                user.getCreatedAt()
        );
    }
}

public record UserResponseV2(Long id, Name name, String email, Instant createdAt) {
    public record Name(String first, String last) {}
}

Shared Logic with a Versioned Facade

Avoid duplicating business logic. Version the response, not the service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api")
public class UserController {

    private final UserService userService;

    @GetMapping("/v1/users/{id}")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        return userService.findById(id).toV1();
    }

    @GetMapping("/v2/users/{id}")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        return userService.findById(id).toV2();
    }
}

Strategy 2: Custom Header Versioning

The version lives in a request header, keeping URLs clean.

1
2
3
4
5
GET /api/users/42
X-API-Version: 1

GET /api/users/42
X-API-Version: 2

Implementation with Custom RequestCondition

Spring MVC doesn’t support header-based versioning out of the box, but you can build it with a custom annotation and RequestCondition:

1
2
3
4
5
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    int value();
}
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
26
27
28
29
30
31
32
33
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {

    private final int version;

    public ApiVersionCondition(int version) {
        this.version = version;
    }

    @Override
    public ApiVersionCondition combine(ApiVersionCondition other) {
        // Method-level annotation takes precedence
        return new ApiVersionCondition(other.version);
    }

    @Override
    public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
        String headerVersion = request.getHeader("X-API-Version");
        if (headerVersion == null) {
            // Default to latest version if no header
            return this;
        }
        int requestedVersion = Integer.parseInt(headerVersion);
        if (requestedVersion == this.version) {
            return this;
        }
        return null; // no match
    }

    @Override
    public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
        return Integer.compare(other.version, this.version); // higher version wins
    }
}

Register the condition via a custom RequestMappingHandlerMapping:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion annotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
    }

    @Override
    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        ApiVersion annotation = AnnotationUtils.findAnnotation(method, ApiVersion.class);
        return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
    }
}
1
2
3
4
5
6
7
8
@Configuration
public class WebMvcConfig implements WebMvcRegistrations {

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new ApiVersionRequestMappingHandlerMapping();
    }
}

Now use it in controllers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    @ApiVersion(1)
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        return userService.findById(id).toV1();
    }

    @GetMapping("/{id}")
    @ApiVersion(2)
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        return userService.findById(id).toV2();
    }
}

Strategy 3: Content Negotiation (Accept Header)

Uses the Accept header with a vendor-specific media type:

1
2
3
4
5
GET /api/users/42
Accept: application/vnd.myapp.v1+json

GET /api/users/42
Accept: application/vnd.myapp.v2+json

Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", produces = "application/vnd.myapp.v1+json")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        return userService.findById(id).toV1();
    }

    @GetMapping(value = "/{id}", produces = "application/vnd.myapp.v2+json")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        return userService.findById(id).toV2();
    }
}

Register custom media types so Spring can serialize them as JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer.defaultContentType(MediaType.APPLICATION_JSON);
    }

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.stream()
                .filter(c -> c instanceof MappingJackson2HttpMessageConverter)
                .map(c -> (MappingJackson2HttpMessageConverter) c)
                .forEach(converter -> {
                    var types = new ArrayList<>(converter.getSupportedMediaTypes());
                    types.add(MediaType.parseMediaType("application/vnd.myapp.v1+json"));
                    types.add(MediaType.parseMediaType("application/vnd.myapp.v2+json"));
                    converter.setSupportedMediaTypes(types);
                });
    }
}

Comparison

CriteriaURI PathCustom HeaderContent Negotiation
VisibilityHigh — version in URLLow — hidden in headersLow — hidden in Accept
CachingEasy — URL-based caching worksRequires Vary headerRequires Vary header
Browser testingEasy — just change URLNeeds tools (curl, Postman)Needs tools
API gateway routingSimple path matchingHeader inspectionHeader inspection
Client complexityLowMediumMedium-High
URL cleanlinessCluttered (/v1/, /v2/)CleanClean
REST purist opinion“Not RESTful”AcceptablePreferred
Real-world adoptionMost common (GitHub, Stripe)Common (Azure)Rare (GitHub alternative)

Deprecation Strategy

Versioning is only half the story. You need a plan for sunsetting old versions.

Sunset Header (RFC 8594)

Signal to clients that a version is going away:

1
2
3
4
5
6
7
8
9
10
@GetMapping("/{id}")
@ApiVersion(1)
public ResponseEntity<UserResponseV1> getUserV1(@PathVariable Long id) {
    UserResponseV1 response = userService.findById(id).toV1();
    return ResponseEntity.ok()
            .header("Sunset", "Sat, 01 Mar 2027 00:00:00 GMT")
            .header("Deprecation", "true")
            .header("Link", "</api/v2/users>; rel=\"successor-version\"")
            .body(response);
}

@Deprecated Annotation for Internal Tracking

1
2
3
4
5
6
@Deprecated(since = "v2", forRemoval = true)
@GetMapping("/{id}")
@ApiVersion(1)
public UserResponseV1 getUserV1(@PathVariable Long id) {
    return userService.findById(id).toV1();
}

Deprecation Workflow

graph LR
    A[v1 Active] --> B[v1 Deprecated + v2 Released]
    B --> C[v1 Sunset Header Added]
    C --> D[v1 Returns 410 Gone]
    D --> E[v1 Code Removed]

diagram

Timeline guidance:

  • Deprecation notice: 6+ months before removal
  • Sunset header: Start immediately when deprecated
  • 410 Gone: After sunset date passes
  • Code removal: After confirming zero traffic on deprecated version

When to Version

Not every change needs a new version. Follow these semantic rules:

Breaking (requires new version):

  • Removing a field from the response
  • Renaming a field
  • Changing a field’s type
  • Removing an endpoint
  • Changing error response format

Non-breaking (safe without versioning):

  • Adding a new field to the response
  • Adding a new endpoint
  • Adding an optional request parameter
  • Fixing a bug (even if output changes)
  • Performance improvements

Gray area (use feature flags or negotiate):

  • Adding a required request parameter
  • Changing default values
  • Changing pagination defaults

A useful rule: if existing clients break without code changes, it needs a version.


Common Problems

ProblemCauseSolution
Duplicated business logicSeparate controllers per versionShare service layer, version only the response mapping
Version explosionToo many versions activeStrict deprecation policy with sunset dates
Testing combinatoricsEvery endpoint × every versionVersion integration tests, share unit tests for business logic
Documentation driftMultiple API docsUse OpenAPI groups per version
Default version confusionNo version header sentDefine a clear default (latest or oldest) and document it
Custom header not forwardedProxy/CDN strips headersWhitelist X-API-Version in your reverse proxy config
Content negotiation 406 errorsCustom media type not registeredRegister vendor types in message converters
Routing conflictsTwo methods match same requestEnsure RequestCondition properly differentiates

Recommendations

  1. Start with URI path versioning — it’s the simplest, most debuggable, and best supported by API gateways and caches
  2. Use header/content negotiation when URL aesthetics matter or you have sophisticated clients (internal microservices, SDKs)
  3. Never version internal APIs between your own services — use backward-compatible evolution instead
  4. Version the API, not individual endpoints — all endpoints should be at the same version
  5. Limit active versions to 2-3 — more than that means your deprecation process is broken

References

This post is licensed under CC BY 4.0 by the author.