Back to Scenarios & Design Questions
Q50Question 50 of 67

What are the strategies for API versioning, what are the tradeoffs, and how do you change a widely used API without breaking existing consumers

Key Concepts
  • Version only for breaking changes; keep every in-version change additive and backward-compatible
  • URI path versioning (/v1/accounts) is the most explicit and easiest to route at a gateway
  • Query-parameter, custom-header, and media-type versioning trade clarity for URL purity
  • Run old and new versions in parallel with a shared service layer and version-specific DTOs
  • Deprecate with Sunset and Deprecation headers before removing any version
  • Prefer expand-and-contract: add the new field, let clients migrate, then remove the old in a future version
  • Treat Kafka event schemas with equal or stricter versioning discipline

Definition

Version only for breaking changes, keep every change within a version additive and optional, run old and new in parallel, and deprecate before you delete.
  • Breaking change – removing or renaming a field, changing a type, making an optional field required.
  • Safe change – adding a new optional field, a new endpoint, or a new optional parameter with a default.
  • URI path versioning – /v1/accounts and /v2/accounts; most explicit, easiest to route.
  • Query-parameter versioning – ?version=2; clean URLs but easy to forget.
  • Custom-header versioning – Api-Version: 2; invisible in browsers.
  • Media-type versioning – Accept: application/vnd.bank.v2+json; RESTfully pure but complex.
  • Deprecation – announce the old version, add Sunset headers, give a concrete window, remove after traffic drains.

Imagine National Bank of India prints account statement forms. Thousands of customers like Rajesh and Priya already know the current form. If the bank suddenly removes a column, everyone's software breaks. Instead, the bank adds a new form labelled "Version 2", keeps printing the old "Version 1" for a while, tells everyone "Version 1 retires next year", and only then stops printing it. Adding an extra optional box to the old form is safe; removing or renaming a box is not.

Understanding the Concepts

What is a breaking change vs a safe change?

The golden rule is to keep making additive changes within a version and only cut a new version when a change is genuinely breaking.

TEXT
Safe (backward-compatible) changes:
  + Add a new optional field to a response
  + Add a new endpoint
  + Add a new optional request parameter with a default
  + Accept a new enum value the consumer can ignore

Breaking changes:
  - Remove or rename a field
  - Change a field's type (String -> Integer)
  - Make an optional field required
  - Change the meaning of an existing value
  - Change an HTTP status code's semantics

What is URI path versioning?

The version goes in the URL path. It is the most explicit and the easiest to route at a gateway. This is the most common choice in banking APIs.

JAVA
package com.bank.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;

@RestController
public class AccountControllerV1 {

    private final AccountService accountService;

    public AccountControllerV1(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/v1/accounts/{accountId}")
    public AccountResponseV1 getAccount(
            @PathVariable String accountId) {
        Account account = accountService.findById(accountId);
        return new AccountResponseV1(
            account.getAccountId(),
            account.getBalance()); // flat balance number
    }
}
JAVA
package com.bank.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AccountControllerV2 {

    private final AccountService accountService;

    public AccountControllerV2(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/v2/accounts/{accountId}")
    public AccountResponseV2 getAccount(
            @PathVariable String accountId) {
        Account account = accountService.findById(accountId);
        return new AccountResponseV2(
            account.getAccountId(),
            new MoneyResponse( // structured balance
                account.getBalance(),
                account.getCurrency()));
    }
}
TEXT
V1 response: {"accountId": "SAV2001", "balance": 150000.00}
V2 response: {"accountId": "SAV2001", "balance": {"amount": 150000.00, "currency": "INR"}}

Breaking change: balance went from flat number to structured object
Both versions served simultaneously from the same service

How do the versioning strategies compare?

| Strategy | Clarity | Gateway Routing | URL Purity | Client Ease |
|---|---|---|---|---|
| URI path (/v1/) | High | Easy | Low | Easy |
| Query param (?v=2) | Medium | Hard | Medium | Easy to forget |
| Custom header | Low | Medium | High | Invisible in browser |
| Media type | Low | Hard | High | Complex for clients |

JAVA
package com.bank.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

// Header-based versioning example
@RestController
public class HeaderVersionedController {

    @GetMapping(value = "/accounts/{id}",
                headers = "Api-Version=1")
    public AccountResponseV1 getAccountV1(String id) {
        // V1 response
        return null;
    }

    @GetMapping(value = "/accounts/{id}",
                headers = "Api-Version=2")
    public AccountResponseV2 getAccountV2(String id) {
        // V2 response
        return null;
    }
}

How do you run versions in parallel?

Keep V1 and V2 controllers both live. Share one service layer underneath and use adapters to map the single internal model into each version's DTO shape.

JAVA
package com.bank.api;

import java.math.BigDecimal;

// V1 DTO: flat balance
public record AccountResponseV1(
    String accountId,
    BigDecimal balance) {}

// V2 DTO: structured balance
public record AccountResponseV2(
    String accountId,
    MoneyResponse balance) {}

public record MoneyResponse(
    BigDecimal amount,
    String currency) {}

// Both DTOs are mapped from the SAME Account entity
// by the respective controllers. No business logic duplication.
TEXT
Shared service layer:
  AccountService.findById(id) -> Account (internal model)

  V1 Controller: Account -> AccountResponseV1 (flat balance)
  V2 Controller: Account -> AccountResponseV2 (structured balance)

  One service, two presentations. No duplicated logic.

How do you deprecate and retire a version?

Publish a deprecation policy: announce, add headers, give a window, monitor, then remove.

JAVA
package com.bank.api;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DeprecatedV1Controller {

    private final AccountService accountService;

    public DeprecatedV1Controller(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/v1/accounts/{accountId}")
    public ResponseEntity<AccountResponseV1> getAccount(
            @PathVariable String accountId) {
        Account account = accountService.findById(accountId);

        return ResponseEntity.ok()
            .header("Deprecation", "true")
            .header("Sunset", "Sat, 01 Mar 2025 00:00:00 GMT")
            .header("Link",
                "</v2/accounts/" + accountId + ">; rel=\"successor-version\"")
            .body(new AccountResponseV1(
                account.getAccountId(),
                account.getBalance()));
    }
}
TEXT
Deprecation timeline:
  Month 1: Announce V1 deprecation, V2 available
  Month 2: Add Deprecation and Sunset headers to V1 responses
  Month 6: Monitor: 95% traffic on V2, 5% on V1
  Month 9: Contact remaining V1 consumers directly
  Month 12: Remove V1 (Sunset date reached)

What is the expand-and-contract pattern?

Add the new field, let clients migrate, then remove the old one in a future version. This avoids coordinated big-bang cutover.

TEXT
Expand-and-contract:
  Phase 1 (expand): V1 returns both "balance" and "balance_v2"
    Existing clients read "balance" (unchanged)
    New clients read "balance_v2" (structured)

  Phase 2 (migrate): clients switch to "balance_v2"
    Monitor: who still reads "balance"?

  Phase 3 (contract): V2 removes "balance", keeps "balance_v2"
    Old field gone, no consumer depends on it

How should event schemas be versioned?

Treat Kafka event schemas with equal or stricter discipline. Old events may be replayed long after the producer changed.

TEXT
Event schema versioning:
  Use Avro or Protobuf with Schema Registry
  Enforce BACKWARD compatibility mode
  Safe: add optional field with default
  Breaking: remove field, change type

  Event replay risk:
    Topic may retain events for 7 days or more
    Old events must still be readable by new consumer
    Schema Registry rejects incompatible changes at publish time

What are common mistakes?

The most common mistake is silently changing behavior within a version. Another is spawning a new version for every release instead of keeping changes additive.

TEXT
WRONG: change field meaning without versioning
  V1 "balance" was in rupees, now it is in paise
  No version bump -> every consumer shows 100x wrong values

WRONG: version per release
  /v1, /v2, /v3, /v4, /v5, /v6...
  Each version has code, tests, support cost
  Unsustainable

CORRECT: version only for breaking changes
  Additive changes go into current version
  Breaking change -> new version
  Result: 2-3 live versions at most

What should a senior engineer know?

Use consumer-driven contract tests so you learn at build time if a change breaks a known consumer. Keep the number of concurrent versions small because every live version is real cost.

| Concern | Solution |
|---|---|
| Detect breakage early | Consumer-driven contract tests (Pact) |
| Minimize version count | Additive changes within version |
| Safe migration | Expand-and-contract pattern |
| Event schemas | Avro/Protobuf + Schema Registry |
| Remove safely | Sunset header + traffic monitoring |

Visual Overview

DIAGRAM
  API Versioning Lifecycle:

  ┌──────────────────────────────────────────┐
  │  Client A (uses V1)                       │
  │  GET /v1/accounts/SAV2001                 │
  │  Response: {"balance": 150000.00}         │
  │  + Deprecation: true                      │
  │  + Sunset: 2025-03-01                     │
  └──────────────┬───────────────────────────┘
                 │
  ┌──────────────▼───────────────────────────┐
  │  Shared Service Layer                     │
  │  AccountService.findById(id) -> Account  │
  └──────────────┬───────────────────────────┘
                 │
  ┌──────────────▼───────────────────────────┐
  │  Client B (uses V2)                       │
  │  GET /v2/accounts/SAV2001                 │
  │  Response: {"balance": {"amount": 150000, │
  │            "currency": "INR"}}             │
  └──────────────────────────────────────────┘

  Expand-and-Contract:
  ┌────────┐    ┌────────────┐    ┌────────┐
  │ V1     │ -> │ V1 + V2    │ -> │ V2     │
  │(old)   │    │(both live) │    │(new)   │
  └────────┘    └────────────┘    └────────┘
  Old clients   Migration window   Old version
  unaffected    both served        removed

Key Takeaway

API versioning is how you evolve a published HTTP contract while letting old and new consumers coexist. The golden rule is to keep making additive changes within a version and only cut a new version when a change is genuinely breaking. Adding an optional field is safe; removing a field, changing a type, or changing meaning requires a new version.

For the transport, URI path versioning is the most common choice in banking because it is the most explicit and easiest to route at a gateway. Behind the scenes, V1 and V2 controllers both live, sharing one service layer with version-specific DTO adapters so business logic is never duplicated. To retire a version, you announce deprecation, add Sunset headers, give consumers a concrete window, monitor traffic, and remove only after it drains.

At National Bank of India, the account API originally returned balance as a flat number. When we went multi-currency, the structured balance with amount and currency was a breaking change, so V1 kept the flat number while V2 returned the structured object. Adding the branch code was purely additive, so it went into V1 with no version bump. The lesson is to prefer expand-and-contract, back changes with consumer-driven contract tests, and treat Kafka event schemas with equal or stricter care.

Remember one sentence: version only for breaking changes, keep every in-version change additive, run old and new in parallel behind a shared service, and deprecate with Sunset headers before you delete.

---

Share
Use ← → arrow keys to navigate