API Versioning Strategies
IntermediateVersion APIs via URI path (/v1/), query parameter, or Accept header; semantic versioning and backward-compatible changes minimise downstream breakage.
Overview
API versioning is a contract management strategy that allows a service to evolve its API without breaking existing consumers. The three main strategies: **URI path versioning** (`/api/v1/orders`) — most visible and cacheable, widely used in public APIs; **query parameter** (`?version=2`) — less RESTful but easy to test; **Accept header** (content negotiation, `Accept: application/vnd.acme.v2+json`) — purist REST, but harder to test in browsers. The most practical internal microservices approach is **URI versioning** for breaking changes, combined with **backward-compatible evolution** (additive changes only) to avoid versioning for most changes. **Consumer-Driven Contract Testing** (Pact) detects breaking changes before they reach production.
URI Path Versioning
The simplest and most widely adopted approach: embed the version in the URI path. Consumers pin to a specific version. Old versions remain available until all consumers have migrated. The gateway or controller routes to the correct version handler. Use a `@RequestMapping("/api/v{version}/")` base path or separate controllers per version.
// Separate controllers per version
@RestController
@RequestMapping("/api/v1/orders")
class OrderControllerV1 {
@GetMapping("/{id}")
OrderResponseV1 getOrder(@PathVariable Long id) {
return orderService.findV1(id);
}
}
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 {
@GetMapping("/{id}")
OrderResponseV2 getOrder(@PathVariable Long id) {
return orderService.findV2(id); // new response shape
}
}
// Or use a single controller with version routing
@RestController
@RequestMapping("/api/{version}/orders")
class OrderController {
@GetMapping("/{id}")
ResponseEntity<?> getOrder(
@PathVariable String version,
@PathVariable Long id) {
return switch (version) {
case "v1" -> ResponseEntity.ok(orderService.findV1(id));
case "v2" -> ResponseEntity.ok(orderService.findV2(id));
default -> ResponseEntity.notFound().build();
};
}
}Header-Based Versioning and Content Negotiation
Header versioning uses a custom `API-Version` header or `Accept` content-type versioning. It keeps URIs clean and follows HTTP semantics more closely, but is harder to use in browsers and makes caching more complex. Spring MVC supports content-type versioning via `produces`/`consumes` attributes on `@GetMapping`.
// Custom header versioning
@GetMapping(value = "/api/orders/{id}",
headers = "X-API-Version=1")
OrderResponseV1 getOrderV1(@PathVariable Long id) {
return orderService.findV1(id);
}
@GetMapping(value = "/api/orders/{id}",
headers = "X-API-Version=2")
OrderResponseV2 getOrderV2(@PathVariable Long id) {
return orderService.findV2(id);
}
// Content-type versioning (vendor MIME type)
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.acme.order.v1+json")
OrderResponseV1 getOrderMediaV1(@PathVariable Long id) { ... }
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.acme.order.v2+json")
OrderResponseV2 getOrderMediaV2(@PathVariable Long id) { ... }
// Consumer request:
// GET /api/orders/123
// Accept: application/vnd.acme.order.v2+jsonBackward-Compatible Evolution and Contract Testing
The best versioning strategy is to avoid it: make **additive-only changes** (new optional fields, new endpoints) that don't break existing consumers. **Pact** (Consumer-Driven Contract Testing) verifies that the provider's response still satisfies what each consumer expects — catching breaking changes in CI before they reach production. A breaking change triggers a version bump; additive changes do not.
// Backward-compatible changes — do NOT require a version bump:
// ✓ Add new optional response field (consumers ignore unknown fields with Jackson)
// ✓ Add a new endpoint
// ✓ Make a required request field optional
// ✓ Add new enum values (if consumers use default handling)
// Breaking changes — REQUIRE a version bump:
// ✗ Remove or rename a field
// ✗ Change a field's type (String → Integer)
// ✗ Make an optional field required
// ✗ Change HTTP status codes for existing responses
// Pact consumer-driven contract test (consumer side)
@ExtendWith(PactConsumerTestExt.class)
class OrderClientPactTest {
@Pact(provider = "order-service", consumer = "checkout-service")
RequestResponsePact getOrderPact(PactDslWithProvider builder) {
return builder
.given("order 123 exists")
.uponReceiving("GET /api/v1/orders/123")
.method("GET").path("/api/v1/orders/123")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.numberValue("id", 123)
.stringValue("status", "PAID"))
.toPact();
}
}Key Points to Remember
- 1URI versioning (/v1/) is most visible, cacheable, and widely used for public APIs
- 2Header versioning keeps URIs clean but complicates caching and browser testing
- 3Best strategy: additive-only changes (no version bump) + explicit versioning only for breaking changes
- 4Breaking changes: remove/rename field, change type, make optional required, change status codes
- 5Backward-compatible: add optional fields, new endpoints, make required fields optional
- 6Pact (Consumer-Driven Contract Testing) catches breaking changes in CI before production
Interview Questions
Sign in to ask AriaWhat are the trade-offs between URI versioning and header-based versioning?
What changes to an API are considered backward-compatible and which are breaking?
What is Consumer-Driven Contract Testing and how does Pact work?
How long should you maintain an old API version after releasing a new one?
How does Jackson's default behaviour help with backward-compatible API evolution?
Ask Aria about API Versioning Strategies
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.