Versioning and Backward Compatibility

Version Numbers and Tags

WhatRecommendedExample
Service releaseSemantic Versioning MAJOR.MINOR.PATCH2.3.1: 2 = breaking, 3 = feature, 1 = fix
Docker image tagRelease version + Git SHA, immutableorder-service:2.3.1, order-service:a9c41e7
REST APIVersion in the path for breaking changes only/api/v1/orders and /api/v2/orders side by side
EventsAdditive changes only; new event type or topic for breaking onesOrderPlaced 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 fieldRemove or rename a field
Add a new endpointChange a field type ("price": "1499" → 1499)
Add a new optional query parameterMake 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.