API Versioning and Deprecation in Spring Boot — Managing Breaking Changes Without Breaking Clients
by Eric Hanson, Backend Developer at Clean Systems Consulting
What counts as a breaking change
Before choosing a versioning strategy, the team must agree on what triggers a new version. Breaking changes break existing clients without any code change on their part:
Breaking:
- Removing an endpoint
- Renaming a required field in a request or response
- Changing a field's type (string → integer, array → object)
- Adding a required field to a request body
- Changing the meaning of an existing field (status
"active"now means something different) - Removing an enum value that clients may send or receive
- Changing authentication requirements for an endpoint
Non-breaking (additive):
- Adding a new endpoint
- Adding an optional field to a request body
- Adding a new field to a response (clients that ignore unknown fields are unaffected)
- Adding a new enum value to a response field (clients should handle unknown values gracefully)
- Making a previously required field optional
The distinction matters because non-breaking changes can be deployed without a version bump. Only breaking changes require a new version.
URL path versioning — the implementation that works
Three versioning strategies exist: URL path (/api/v1/), request header (Accept: application/vnd.api.v1+json), and query parameter (?version=1). URL path versioning is the most practical:
- Visible in browser address bars and curl commands
- Easy to test and debug
- Simple to route at the load balancer or API gateway level
- Logs show the version being called
- No special client configuration required
// v1 controller
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders v1")
public class OrderControllerV1 {
private final OrderService orderService;
@GetMapping("/{id}")
public OrderResponseV1 getOrder(@PathVariable Long id) {
return OrderResponseV1.from(orderService.findOrder(id));
}
@PostMapping
public ResponseEntity<OrderResponseV1> createOrder(
@RequestBody @Valid CreateOrderRequestV1 request) {
Order order = orderService.createOrder(request.toCommand());
return ResponseEntity
.created(URI.create("/api/v1/orders/" + order.getId()))
.body(OrderResponseV1.from(order));
}
}
// v2 controller — exists when v1 has breaking changes
@RestController
@RequestMapping("/api/v2/orders")
@Tag(name = "Orders v2")
public class OrderControllerV2 {
private final OrderService orderService;
@GetMapping("/{id}")
public OrderResponseV2 getOrder(@PathVariable Long id) {
return OrderResponseV2.from(orderService.findOrder(id));
}
}
The v1 and v2 controllers share the same service layer. The service evolves; the v1 and v2 response objects handle the mapping differences:
// v1 response — original shape
public record OrderResponseV1(
Long id,
String status,
BigDecimal total, // decimal, not money object
String currency
) {
public static OrderResponseV1 from(Order order) {
return new OrderResponseV1(order.getId(), order.getStatus().name(),
order.getTotal().amount(), order.getTotal().currency().code());
}
}
// v2 response — money as object, status as typed value
public record OrderResponseV2(
Long id,
OrderStatus status, // typed enum, not string
MoneyResponse total // nested money object
) {
public static OrderResponseV2 from(Order order) {
return new OrderResponseV2(order.getId(), order.getStatus(),
MoneyResponse.from(order.getTotal()));
}
}
The service Order domain model is not version-specific. Both response classes adapt the domain model to the version contract.
Version routing at the API gateway level
For large APIs, version routing at the gateway avoids duplicating controllers. The gateway routes /api/v1/* to the v1 pods and /api/v2/* to the v2 pods. Both versions can run as separate deployments:
# NGINX or Kong routing
location /api/v1/ {
proxy_pass http://order-service-v1:8080/api/v1/;
}
location /api/v2/ {
proxy_pass http://order-service-v2:8080/api/v2/;
}
This enables independent deployment and independent rollback of each version. v1 stays on a stable release; v2 deploys with new features. The tradeoff: running multiple deployment targets increases operational complexity.
For most services, keeping both versions in the same codebase and deployment is simpler and sufficient.
Deprecation headers — notifying clients before removal
The standard HTTP deprecation mechanism uses Deprecation and Sunset headers:
@Component
public class DeprecationHeaderFilter extends OncePerRequestFilter {
private static final Map<String, DeprecationInfo> DEPRECATED_PATHS = Map.of(
"/api/v1/orders", new DeprecationInfo(
Instant.parse("2026-01-01T00:00:00Z"), // deprecation date
Instant.parse("2026-07-01T00:00:00Z"), // removal date
"/api/v2/orders" // successor
)
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
chain.doFilter(request, response);
// Add headers after response is processed
String path = request.getRequestURI();
DEPRECATED_PATHS.entrySet().stream()
.filter(entry -> path.startsWith(entry.getKey()))
.findFirst()
.ifPresent(entry -> {
DeprecationInfo info = entry.getValue();
response.addHeader("Deprecation",
info.deprecatedAt().toString());
response.addHeader("Sunset",
info.sunsetAt().toString());
response.addHeader("Link",
"<" + info.successor() + ">; rel=\"successor-version\"");
});
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return DEPRECATED_PATHS.keySet().stream()
.noneMatch(path::startsWith);
}
record DeprecationInfo(Instant deprecatedAt, Instant sunsetAt, String successor) {}
}
Using Spring MVC's @Deprecated and documentation:
@GetMapping("/{id}")
@Deprecated // Java annotation — signals to IDE and tooling
@Operation(deprecated = true, // OpenAPI — shows in Swagger UI as deprecated
description = "Deprecated. Use /api/v2/orders/{id} instead. " +
"Will be removed 2026-07-01.")
public OrderResponseV1 getOrder(@PathVariable Long id) { ... }
The @Operation(deprecated = true) shows a strikethrough in Swagger UI and marks the endpoint in the OpenAPI spec. Combined with the Deprecation header, clients have both programmatic and documentation-level notification.
Monitoring version usage before removal
Before removing a deprecated version, verify no clients are still using it. Add a metric counter to deprecated endpoints:
@Component
public class ApiVersionMetricsFilter extends OncePerRequestFilter {
private final MeterRegistry meterRegistry;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String path = request.getRequestURI();
String version = extractVersion(path); // "v1", "v2", or "unknown"
chain.doFilter(request, response);
meterRegistry.counter("api.requests",
"version", version,
"method", request.getMethod(),
"status", String.valueOf(response.getStatus())
).increment();
}
private String extractVersion(String path) {
if (path.startsWith("/api/v1/")) return "v1";
if (path.startsWith("/api/v2/")) return "v2";
return "unknown";
}
}
Dashboard query in Prometheus:
sum(rate(api_requests_total{version="v1"}[5m])) by (endpoint)
A dashboard showing v1 traffic per endpoint over time identifies which endpoints are still being called and by whom (combine with user/API key from MDC). Zero v1 traffic for 30 days is the signal that removal is safe.
Alert when v1 traffic appears after a planned removal date — someone missed the deprecation notice.
The deprecation timeline that works
A realistic deprecation timeline for an external API:
Month 0: v2 released, v1 deprecated
→ Deprecation headers added to v1 responses
→ v2 announced in changelog and developer communications
→ Migration guide published
Month 1: Active client notification
→ Identify clients still calling v1 from usage metrics
→ Email/contact known integrators directly
→ Developer portal shows v1 as deprecated
Month 3: Soft sunset
→ v1 returns 301 Redirect to v2 for endpoints where safe
→ Log warnings for remaining v1 callers with their client identifiers
→ Support for v1 questions ends
Month 6: Hard sunset (Sunset header date)
→ v1 returns 410 Gone
→ Keep 410 handler for 3 months, then fully remove
The 6-month minimum for external APIs is standard — integrators may have quarterly release cycles and need time to plan and ship updates.
For internal APIs (service-to-service within your organization), the timeline can compress significantly — direct communication replaces developer portal announcements, and deployment is coordinated.
Version negotiation — letting clients request versions
An alternative to URL versioning: a default URL path with version negotiation via header:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public ResponseEntity<?> getOrder(@PathVariable Long id,
@RequestHeader(value = "Accept-Version",
defaultValue = "2") int version) {
Order order = orderService.findOrder(id);
return switch (version) {
case 1 -> ResponseEntity.ok(OrderResponseV1.from(order));
case 2 -> ResponseEntity.ok(OrderResponseV2.from(order));
default -> ResponseEntity.badRequest()
.body(new ErrorResponse("unsupported_version",
"Supported versions: 1, 2"));
};
}
}
This keeps URLs stable across versions — clients that don't specify a version get the latest. The tradeoff: the version is invisible in logs and browser testing without extra tooling. Most teams choose URL versioning over header versioning for its visibility and simplicity.
Breaking change detection in CI
Prevent breaking changes from being merged without a version bump. Compare the current OpenAPI spec against the last published version:
# .github/workflows/api-compat.yml
- name: Check for breaking API changes
run: |
npx @optic/cli diff \
https://api.staging.example.com/v3/api-docs \
./target/openapi.json \
--check
Or use openapi-diff:
docker run --rm -v $(pwd):/data openapitools/openapi-diff:latest \
/data/published-openapi.json \
/data/target/openapi.json \
--fail-on-incompatible
--fail-on-incompatible exits with a non-zero code when breaking changes are detected. The CI job fails, requiring the developer to either:
- Increment the version number in the new URL path
- Confirm the change is actually non-breaking and add it to an allow list
Breaking change detection in CI is the forcing function that makes versioning discipline automatic rather than aspirational.
The practical minimum
Every externally-facing API needs three things:
A versioning strategy applied from day one. /api/v1/ in the URL from the first endpoint. Retrofitting versioning onto an unversioned API is painful; clients have to change URLs they weren't expecting to change.
Deprecation headers on every deprecated endpoint. The Deprecation and Sunset headers give clients machine-readable signal. Good HTTP clients log or surface these automatically.
Usage monitoring before removal. Never remove a version until you can prove no one is calling it. The 410 Gone response after sunset is the safety net for clients who missed the notices — it's explicit feedback rather than a confusing connection refused or 404.
The versioning process is not glamorous. It's overhead. The alternative — breaking existing clients every time the API evolves — is worse.