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

@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

  1. Add optional fields only (at end of JSON)
  2. Never remove or rename fields (old clients break)
  3. Never change field type (number → string breaks parsing)
  4. 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):

{
  "total_price": 99.99  // Renamed from totalPrice
}

Do this (backward compatible):

{
  "totalPrice": 99.99,        // Keep old field
  "total_price": 99.99        // Add new field (alias)
}

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.