Definition
- 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/accountsand/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.
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.
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
}
}
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()));
}
}
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 |
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.
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.
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.
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()));
}
}
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.
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.
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.
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
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.
---