The moment an API has external consumers, you can no longer change it freely. A versioning strategy gives you a way to introduce breaking changes while old clients keep working against the contract they were built for.
URI versioning
The simplest and most visible approach puts the version in the path. It's easy to route, cache, and reason about.
@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 {
@GetMapping("/{id}")
public ProductV1 get(@PathVariable Long id) {
return service.findV1(id);
}
}Header versioning
If you'd rather keep URLs stable, negotiate the version through a header. Spring can route on header values directly.
@GetMapping(value = "/api/products/{id}",
headers = "X-API-Version=2")
public ProductV2 getV2(@PathVariable Long id) {
return service.findV2(id);
}Isolate versions behind a stable core
Don't fork your whole service per version. Keep one domain model and map to version-specific DTOs at the edge.
public ProductV2 findV2(Long id) {
Product product = repository.findById(id).orElseThrow();
return ProductV2.from(product); // version lives only in the DTO
}Takeaways
- Pick one strategy — URI or header — and apply it consistently.
- Keep a single domain model and vary only the DTO mapping per version.
- Document a deprecation policy so clients know when a version will retire.
Whatever you choose, treat a published version as frozen. New behavior goes in a new version, never retrofitted into an old one.