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:

  1. Increment the version number in the new URL path
  2. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why “Don’t Touch This Code” Is a Huge Engineering Red Flag

Hearing “don’t touch this code” might seem like harmless advice, but it often signals deep problems in a codebase and the team culture around it.

Read more

Spring Data Repository Design — When findBy Methods Are Enough and When They're Not

Spring Data's derived query methods eliminate boilerplate for simple queries. They become unreadable for complex ones and break entirely for dynamic filtering. Here is where each approach belongs and how to recognize when you've outgrown derived queries.

Read more

Boston Produces World-Class Engineers — Then Biotech and Finance Take Them All

MIT, Northeastern, BU — Boston graduates some of the best developers in the country. Most of them never work at a startup.

Read more

Why Dubai Startups Lose Backend Engineers to Better Offers Every 18 Months

You relocated him from Lahore, sponsored his visa, found him an apartment in JLT. Eighteen months later he's joining a fintech in DIFC for 30% more.

Read more