API Versioning & Evolution — Deep Dive
Level: Intermediate Pre-reading: 05 · API & Communication
The Versioning Problem
Services evolve. APIs change. But clients depend on old contracts.
Timeline:
Day 1: Payment API v1.0 returns { price: 99.99, tax: 10.00 }
Day 2: Payment team adds shipping: { price: 99.99, tax: 10.00, shipping: 5.00 }
Day 3: Old client code expects only { price, tax }
→ Crashes on unknown field `shipping` ✗
API Evolution lets you change APIs without breaking clients.
Versioning Strategies
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v1/orders vs /api/v2/orders |
Explicit; easy to route | Duplication; operational overhead |
| Query param | /api/orders?version=2 |
Lightweight | Harder to route; less discoverable |
| Header | Accept: application/vnd.mycompany.v2+json |
Clean URLs | Not visible in browser; tools unaware |
| Media Type | Accept: application/json; version=2 |
Part of HTTP contract | Uncommon; not all clients support |
Recommended: URL Path Versioning
@RestController
public class OrderController {
@GetMapping("/api/v1/orders/{id}")
public OrderV1 getOrderV1(@PathVariable String id) {
// Old contract: { id, totalPrice }
return new OrderV1(id, 99.99);
}
@GetMapping("/api/v2/orders/{id}")
public OrderV2 getOrderV2(@PathVariable String id) {
// New contract: { id, subtotal, tax, shipping, total }
return new OrderV2(id, 89.99, 10.00, 5.00, 104.99);
}
}
class OrderV1 {
public String id;
public double totalPrice;
}
class OrderV2 {
public String id;
public double subtotal;
public double tax;
public double shipping;
public double total;
}
Backward Compatibility (No Versioning Needed)
Best approach: Don't version if you can evolve backward-compatibly.
Rules
- Add optional fields only (at end of JSON)
- Never remove or rename fields (old clients break)
- Never change field type (number → string breaks parsing)
- Old clients ignore unknown fields (JSON parsers default)
Example: Add Field Without Breaking
Old API response:
{
"id": "ORD-123",
"totalPrice": 99.99
}
New API adds shipping:
{
"id": "ORD-123",
"totalPrice": 99.99,
"shipping": 5.00 ← New field
}
Old client: Ignores "shipping"; still sees id + totalPrice ✓
Example: Rename Field Carefully
❌ Don't do this (breaks clients):
✅ Do this (backward compatible):
Or deprecate gradually:
Year 1: Support both totalPrice + total_price
Year 2: Announce "totalPrice deprecated; use total_price"
Year 3: Remove totalPrice
Deprecation Strategy
When you MUST break clients:
Phase 1: Announce (Months 1–2)
API Deprecation Notice
Endpoint: GET /api/v1/orders/{id}
Deprecated: 2026-06-01
Sunset: 2026-09-01
Migrate to: GET /api/v2/orders/{id}
See: https://docs.myapi.com/migration-guide
Phase 2: Add Warnings (Months 1–9)
@GetMapping("/api/v1/orders/{id}")
public ResponseEntity<OrderV1> getOrderV1(@PathVariable String id) {
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sun, 01 Sep 2026 00:00:00 GMT")
.header("Link", "</api/v2/orders/{id}>; rel=\"successor-version\"")
.body(orderV1);
}
Client sees headers:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sun, 01 Sep 2026 00:00:00 GMT
Link: </api/v2/orders/{id}>; rel="successor-version"
Phase 3: Sunsetting (Month 10)
Stop responding to v1; force migration.
Contract Evolution Patterns
Adding a Field
Before: { id, name }
After: { id, name, email }
Backward compatible? YES
Old clients ignore email; v1 still works
Removing a Field
Before: { id, name, deprecated_field }
After: { id, name }
Backward compatible? NO
Old clients expect deprecated_field; missing field breaks parsing
Solution:
1. Keep field with default value for 6+ months
2. Document deprecation
3. Set sunset date
4. Remove
Changing Field Type
Before: { orderId: "ORD-123" } // string
After: { orderId: 12345 } // number
Backward compatible? NO
JSON parsers fail; strongly typed clients break
Solution: Don't do this. Use versioning if necessary.
Restructuring (Nested Objects)
Before: { firstName: "John", lastName: "Doe" }
After: { name: { first: "John", last: "Doe" } }
Backward compatible? NO
Clients expect flat structure
Solution: Use a transition period with dual fields:
{
firstName: "John", // Old field
lastName: "Doe",
name: { // New field
first: "John",
last: "Doe"
}
}
After 6 months: Remove firstName + lastName
Internal vs External APIs
| Aspect | Internal (Service-to-Service) | External (Client-Facing) |
|---|---|---|
| Breaking changes | Can version aggressively; few clients to coordinate | Must support old versions; many uncontrolled clients |
| Deprecation period | 1–2 sprints | 6–12 months |
| Default behavior | Be strict; fail on unknown fields | Be lenient; ignore unknown fields |
Internal API Example (Strict)
// Between services: fail fast on contract violation
@JsonDeserialize(using = OrderEventDeserializer.class)
public class OrderEvent {
public static class OrderEventDeserializer extends StdDeserializer<OrderEvent> {
@Override
public OrderEvent deserialize(JsonParser p, DeserializationContext ctxt) {
// Fail if unknown fields present (contract violation)
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES = true;
// ...
}
}
}
External API Example (Lenient)
// For public API: ignore unknown fields
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderResponse {
public String id;
public double totalPrice;
// New fields added in future; old clients don't see them
}
Semantic Versioning for APIs
Follow Semantic Versioning (semver): MAJOR.MINOR.PATCH
| Version | Change | Example |
|---|---|---|
| MAJOR | Breaking change | Remove field; change field type |
| MINOR | Backward-compatible addition | Add optional field; new endpoint |
| PATCH | Bug fix | Fix calculation error |
v1.0.0 → v1.1.0 (added optional field "shipping") → backward compat
v1.0.0 → v1.0.1 (fixed tax calculation bug) → no API change
v1.0.0 → v2.0.0 (removed "tax" field) → breaking
Practical Multi-Version Support
In Spring Boot:
// Route based on URL version
@RestController
@RequestMapping("/api")
public class OrderController {
@GetMapping("/v1/orders/{id}")
public OrderV1 getV1(@PathVariable String id) {
Order order = getOrder(id);
return new OrderV1(order); // DTO translation
}
@GetMapping("/v2/orders/{id}")
public OrderV2 getV2(@PathVariable String id) {
Order order = getOrder(id);
return new OrderV2(order); // Newer DTO
}
private Order getOrder(String id) {
// Shared logic; both endpoints use this
return repository.findById(id).orElseThrow();
}
}
// DTOs shield from changes
class OrderV1 {
public String id;
public double totalPrice;
OrderV1(Order order) {
this.id = order.id;
this.totalPrice = order.subtotal + order.tax;
}
}
class OrderV2 {
public String id;
public double subtotal;
public double tax;
public double shipping;
OrderV2(Order order) {
this.id = order.id;
this.subtotal = order.subtotal;
this.tax = order.tax;
this.shipping = order.shipping;
}
}
Monitoring API Version Usage
Track which clients use old versions; prioritize migration efforts:
@RestController
public class OrderController {
@Autowired
private MeterRegistry meterRegistry;
@GetMapping("/api/v1/orders/{id}")
public Order getV1(@PathVariable String id) {
meterRegistry.counter("api.version", "version", "v1").increment();
return getOrder(id);
}
@GetMapping("/api/v2/orders/{id}")
public Order getV2(@PathVariable String id) {
meterRegistry.counter("api.version", "version", "v2").increment();
return getOrder(id);
}
}
Then query:
api.version{version="v1"} = 100 requests/day ← Schedule v1 sunset
api.version{version="v2"} = 1000 requests/day
Should I version major endpoints or the entire API?
Version entire API (/api/v1/, /api/v2/). This is clearer for clients; avoids mixing versions in one request.
How long should I support old API versions?
Internal APIs: 1–2 releases. External public APIs: 6–12 months minimum. Enterprise customers: 2–3 years. Monitor usage; don't support versions with < 1% traffic.
Can I deprecate a field without versioning?
Yes, if you don't remove it. Keep the field; document as deprecated; ignore it in v2+ code. After 6+ months, remove quietly. Most clients won't break.