Careeroza
Versioning and Backward Compatibility
Version Numbers and Tags
| What | Recommended | Example |
|---|---|---|
| Service release | Semantic Versioning MAJOR.MINOR.PATCH | 2.3.1: 2 = breaking, 3 = feature, 1 = fix |
| Docker image tag | Release version + Git SHA, immutable | order-service:2.3.1, order-service:a9c41e7 |
| REST API | Version in the path for breaking changes only | /api/v1/orders and /api/v2/orders side by side |
| Events | Additive changes only; new event type or topic for breaking ones | OrderPlaced v2 adds currency |
Services are released independently, so at any moment old and new versions talk to each other. The golden rule: a change is safe if old consumers ignore it and new consumers survive without it.
Safe and Breaking Changes
| Safe (backward compatible) | Breaking |
|---|---|
| Add an optional JSON field | Remove or rename a field |
| Add a new endpoint | Change a field type ("price": "1499" → 1499) |
| Add a new optional query parameter | Make an optional field required |
| Add a new enum value (if consumers tolerate unknown values) | Change the meaning of a field (paise instead of rupees) |
// Tolerant reader: ignore fields you do not know (Spring Boot's ObjectMapper already does this)
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderPlaced(Long orderId, BigDecimal amount, String currency) {
public OrderPlaced {
if (currency == null) currency = "INR"; // default for old (v1) producers
}
}Example: A Consumer That Survives Schema Changes
Example
Three versions of the OrderPlaced event arrive on the same topic. v2 adds optional fields, which the tolerant consumer handles. v3 renamed amount, which breaks every old consumer: the message goes to a dead-letter topic. (Messages are key=value strings here instead of JSON to keep the program dependency-free.)
import java.util.*;
public class Main {
public static void main(String[] args) {
String[] messages = {
"version=1,orderId=1001,amount=2998",
"version=2,orderId=1002,amount=1499,currency=INR,couponCode=DIWALI10",
"version=3,orderId=1003,total=999"
};
for (String m : messages) {
try {
System.out.println("ok " + OrderPlaced.from(parse(m)));
} catch (IllegalArgumentException e) {
System.out.println("FAIL " + e.getMessage() + " -> sent to order-events.DLT");
}
}
}
static Map<String, String> parse(String message) {
Map<String, String> fields = new LinkedHashMap<>();
for (String kv : message.split(",")) {
String[] parts = kv.split("=", 2);
fields.put(parts[0], parts[1]);
}
return fields;
}
}
record OrderPlaced(long orderId, int amount, String currency) {
static OrderPlaced from(Map<String, String> f) {
String amount = f.get("amount");
if (amount == null) {
throw new IllegalArgumentException("order " + f.get("orderId") + ": required field 'amount' missing");
}
// unknown fields (couponCode) are ignored; missing optional fields get defaults
return new OrderPlaced(Long.parseLong(f.get("orderId")), Integer.parseInt(amount),
f.getOrDefault("currency", "INR"));
}
}Output
ok OrderPlaced[orderId=1001, amount=2998, currency=INR] ok OrderPlaced[orderId=1002, amount=1499, currency=INR] FAIL order 1003: required field 'amount' missing -> sent to order-events.DLT
Tip: Catch breaking changes before production: consumer-driven contract tests (Spring Cloud Contract, Pact) fail the producer's build, and a schema registry (Confluent, Apicurio) with Avro/Protobuf rejects incompatible event schemas.