Java

Spring Boot API Versioning Best Practices: 4 Proven Strategies That Prevent Breaking Clients

Learn Spring Boot API versioning with 4 proven strategies, DTO evolution, and testing tips to prevent breaking clients and ship safely.

Spring Boot API Versioning Best Practices: 4 Proven Strategies That Prevent Breaking Clients

I remember the exact moment I learned why API versioning matters. I was three months into a new project, proudly shipping features that customers loved. Then came the request: “Can we rename the firstName and lastName fields to fullName?” Easy change, I thought. I deployed on a Friday. Monday morning, our support inbox was flooded. Seven existing integrations broke because their JSON parsers expected the old field names. That weekend, I built my first versioned endpoint. I never skipped versioning again.

Today, I’ll walk you through the same patterns I now use in every production Spring Boot API. We’ll cover four proven versioning strategies, real DTO evolution, and testing tricks that keep your clients happy even as your API grows. No theory — just code that works.


The core problem: breaking changes are inevitable

You cannot keep an API frozen forever. Business rules change. New fields are required. Old fields become confusing. But every time you modify a response structure, you risk breaking clients you might not even know exist. What if a mobile app takes two weeks to update? What if a partner’s integration was written three years ago? API versioning gives you a controlled way to change — one version at a time.

But which versioning method should you choose? Let’s start with the most straightforward one.


Strategy 1: URI path versioning

The version lives right in the URL. GET /api/v1/users/1 and GET /api/v2/users/1 point to different controllers or methods. It’s explicit, easy to document, and simple to test. I use this approach when I have a small number of versions and the clients are known (internal teams, partner APIs).

Here’s how I implement it in Spring Boot. I create separate controller methods for each version, mapping them to different paths.

@RestController
public class UserUriController {

    private final UserService service;

    public UserUriController(UserService service) {
        this.service = service;
    }

    @GetMapping("/api/v1/users/{id}")
    public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV1(id));
    }

    @GetMapping("/api/v2/users/{id}")
    public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV2(id));
    }
}

The DTOs change between versions. V1 has firstName and lastName. V2 replaces them with fullName. Simple.

// V1
public record UserV1(Long id, String username, String firstName, String lastName, String email) {}

// V2
public record UserV2(Long id, String username, String fullName, String email) {}

The service layer decides which version to serve. I usually have one internal representation and a mapper that produces the correct DTO.

Question: Do you duplicate business logic across versions? No — the same service method can return a generic object, and the controller or a converter handles the transformation. That keeps your code dry.

The downside? URL pollution. Every new version means another path. Clients must hardcode the version. And if you have twenty versions, your @GetMapping list becomes a monster. Yet for most real‑world APIs, this is the clearest choice.


Strategy 2: Request header versioning (custom header)

Instead of cluttering the URL, you place the version in an HTTP header. X-API-Version: 2. This keeps the endpoint clean: GET /api/users/1. The client sends the version they want, and the server routes accordingly.

Spring Boot doesn’t have built‑in header‑based routing, but you can write a simple interceptor or use @RequestMapping with headers.

@RestController
public class UserHeaderController {

    @GetMapping(
        value = "/api/users/{id}",
        headers = "X-API-Version=1"
    )
    public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV1(id));
    }

    @GetMapping(
        value = "/api/users/{id}",
        headers = "X-API-Version=2"
    )
    public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV2(id));
    }
}

Wait — what if the client doesn’t send any version header? That’s a design decision. I usually default to the latest stable version and return a deprecation warning header informing them they should specify a version. This works well for public APIs where you want to encourage adoption.

Header versioning is cleaner for URLs, but it’s invisible to caching proxies that only look at the path. Also, not all HTTP clients handle custom headers easily. I use this approach when I want the same endpoint to serve multiple formats (like JSON vs XML) with version on top.


Strategy 3: Accept header / content negotiation versioning

This is the REST‑purist approach. The client specifies the version inside the Accept header using a custom media type: Accept: application/vnd.myapp.v2+json. The server matches the media type and serves the correct representation.

Spring Boot supports this natively with produces attribute.

@RestController
public class UserContentNegotiationController {

    @GetMapping(
        value = "/api/users/{id}",
        produces = "application/vnd.myapp.v1+json"
    )
    public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV1(id));
    }

    @GetMapping(
        value = "/api/users/{id}",
        produces = "application/vnd.myapp.v2+json"
    )
    public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
        return ResponseEntity.ok(service.findV2(id));
    }
}

The browser won’t easily set a custom Accept header, so this method is best for machine‑to‑machine APIs. It’s extensible — you can request different versions, formats (JSON vs XML), or even different representation profiles. I’ve used it for a public REST API that needed to support legacy clients for years.

Question: How do you tell clients which versions exist? You should return a Link header or include version support in your API documentation. Springdoc can document each media type variant.

This strategy avoids URL changes entirely, but it’s less discoverable. A developer can’t just type a URL into a browser and see what happens. You need a good client library.


Strategy 4: Query parameter versioning

Add a query parameter: GET /api/users/1?version=2. This is the simplest to implement and test, but also the easiest for clients to forget. It’s not cache‑friendly (different query strings often means different caches). I rarely use this in production because it hides the version in a place that’s easy to override accidentally.

@RestController
public class UserQueryParamController {

    @GetMapping("/api/users/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestParam(defaultValue = "1") int version) {
        return switch (version) {
            case 1 -> ResponseEntity.ok(service.findV1(id));
            case 2 -> ResponseEntity.ok(service.findV2(id));
            default -> throw new IllegalArgumentException("Unsupported version: " + version);
        };
    }
}

I include this only for completeness. It works for quick internal prototypes, but for anything that must be maintained over years, choose a more explicit strategy.


Building a reusable versioning framework

If you manage many endpoints across multiple versions, you’ll want to avoid repeating the version logic in every controller. I created a custom annotation and a RequestMappingHandlerMapping extension that selects the correct controller based on a version header.

Here’s a simplified version — an annotation @ApiVersion that you place on the controller class.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    String value();
}

Then a custom RequestMappingHandlerMapping that reads this annotation and determines which handler to use based on a header. I won’t paste the whole class here (it’s about 50 lines), but the idea is to create a CompositeHandlerMapping that falls through versions. This is useful for a large API where you want the version logic centralized.


Evolving DTOs gracefully (Jackson tricks)

Versioning isn’t just about endpoints — it’s about data. When you change a field name, old clients break. I use Jackson annotations to handle backward compatibility.

For example, if V2 has fullName but you still want to accept firstName and lastName from legacy clients, you can use @JsonAlias and @JsonAnySetter.

public record UserV2(
    Long id,
    String username,
    @JsonAlias({"firstName", "lastName"}) String fullName,
    String email
) {}

When a client sends firstName and lastName, Jackson maps them both to fullName via a custom deserializer. On the response side, you can output both old and new fields using @JsonProperty on a field and a getter.

@JsonIgnoreProperties(ignoreUnknown = true)
public class UserV2Response {
    private Long id;
    private String username;
    private String fullName;
    private String email;

    // For backward compatibility
    @JsonProperty("firstName")
    public String getFirstName() {
        return fullName.split(" ")[0];
    }

    @JsonProperty("lastName")
    public String getLastName() {
        return fullName.substring(fullName.indexOf(' ') + 1);
    }
}

Question: Should you always include legacy fields? Only if you have strong evidence that clients depend on them. Otherwise, you increase the payload size and confuse new developers. Use deprecation headers to signal that old fields are temporary.


Testing versioned endpoints

With Spring Boot’s MockMvc, you can test each version independently. I always test the exact same request against multiple versions to make sure the response structure matches expectations.

@WebMvcTest(UserUriController.class)
class UserUriControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void testV1Response() throws Exception {
        mockMvc.perform(get("/api/v1/users/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.firstName").value("John"))
               .andExpect(jsonPath("$.lastName").value("Doe"))
               .andExpect(jsonPath("$.fullName").doesNotExist());
    }

    @Test
    void testV2Response() throws Exception {
        mockMvc.perform(get("/api/v2/users/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.fullName").value("John Doe"))
               .andExpect(jsonPath("$.firstName").doesNotExist());
    }
}

For header‑based versioning, I test with custom headers.

@Test
void testHeaderVersioning() throws Exception {
    mockMvc.perform(get("/api/users/1")
               .header("X-API-Version", "1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.firstName").exists());
}

I also add integration tests with TestRestTemplate to verify the whole stack works. Versioning is one of those layers where bugs hide in the wiring, not the logic.


Deprecating old versions (the responsible way)

Every version eventually should be retired. I send a Sunset header and a Deprecation header in every response for older versions. This gives clients a clear timeline.

@GetMapping(headers = "X-API-Version=1")
public ResponseEntity<UserV1> getV1(@PathVariable Long id) {
    return ResponseEntity.ok()
        .header("Deprecation", "true")
        .header("Sunset", "Sat, 01 Nov 2025 00:00:00 GMT")
        .body(service.findV1(id));
}

I also use a Spring AOP aspect that logs every call to a deprecated endpoint and sends a notification to our API monitoring. When the sunset date passes, we return a 410 Gone status. No surprises.


Putting it all together

I typically start a new API with URI path versioning because it’s the easiest for everyone to understand. Once the API matures and we have a dedicated client library, I might migrate to header‑based versioning to clean up the URLs. But for most projects, path versioning is fine.

The key is to pick one method and stick with it. Mixing strategies (some endpoints use path, others use headers) creates confusion. Document the chosen method clearly in your OpenAPI spec. Use springdoc to generate version‑specific documentation.

Here’s how I configure OpenAPI to show multiple versions (conceptual snippet):

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

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

Now you have separate Swagger UI pages for each version. Clients appreciate that.


What I’ve learned the hard way

  • Keep versions semantic: every new version should have a clear reason (breaking change, not cosmetic).
  • Never remove a field without a deprecation period and clear communication.
  • Test every version against a representative set of client payloads.
  • Automate version bumping in your CI pipeline — it’s too easy to forget.

One more thing: versioning your API is not just about the endpoints. It’s about the contract between you and every developer who trusts your data. Treat that trust with care.


Now it’s your turn. Which of these versioning strategies fits your current project? I’d love to hear how you handle backward compatibility in your APIs. If you found this guide useful, please like, share, and drop a comment below — tell me about the versioning disaster you survived (or the one you prevented). Your story could help someone else avoid that Friday‑night emergency.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Similar Posts

Keep Reading