Skip to main content
event sourcing and cqrs in practice

Event Schema Evolution: Adding Fields, Renaming Events, and the Upcasting Chain

10 min read Chapter 12 of 17

Event Schema Evolution

Upcasting chain

When event schemas change, upcasters transform old payloads to the current schema before deserialization. A V1 event (with shippingAddress and no version field) passes through a V1-to-V2 upcaster (which renames the field and adds defaults), then through a V2-to-V3 upcaster if needed. The chain runs automatically during event loading. Safe, upcaster-required, and migration-required changes each have different risk profiles.

An OrderPlaced event written today will be read by application code deployed months from now. The event’s JSON payload is frozen at write time. The Java class that deserializes it evolves with every deployment. When the business adds a loyalty program and the OrderPlaced event needs a loyaltyTier field, the millions of existing OrderPlaced events in the event store do not have that field.

This is the fundamental challenge of event schema evolution: events are immutable, but the code that reads them is not.

Three categories of schema changes exist, each with a different risk profile and a different solution.

Additive Changes

The Problem

The business adds a promotion engine. The OrderPlaced event now needs a promotionCode field. New orders include the promotion code. Old orders do not have one. The aggregate’s apply method and every projection that processes OrderPlaced must handle both old events (without promotionCode) and new events (with promotionCode).

The Mechanism

Additive changes are the safest category. A new field is added to the event class. The ObjectMapper is configured to ignore unknown properties (FAIL_ON_UNKNOWN_PROPERTIES = false) and to treat missing fields as null or a default value.

// FROM SCRATCH
// Version 1: Original
public record OrderPlacedV1(
    String orderId,
    String customerId,
    List<LineItem> lineItems,
    BigDecimal total,
    Address shippingAddress,
    Instant occurredAt
) {}

// Version 2: Added promotionCode
public record OrderPlaced(
    String orderId,
    String customerId,
    List<LineItem> lineItems,
    BigDecimal total,
    Address shippingAddress,
    Instant occurredAt,
    @JsonProperty(defaultValue = "") String promotionCode,
    @JsonProperty(defaultValue = "1") int schemaVersion
) {
    public OrderPlaced {
        if (promotionCode == null) promotionCode = "";
        if (schemaVersion == 0) schemaVersion = 2;
    }
}

When deserializing a V1 event (no promotionCode in the JSON), Jackson uses the default value from @JsonProperty or the record’s compact constructor sets the default. The code handles both versions without an upcaster.

What the Implementation Reveals

The schemaVersion field is a convention, not a framework feature. Every event carries its version number. When the event is serialized, the version is included in the JSON. When the event is deserialized, the version tells the reader which schema to expect.

The compact constructor in Java records (public OrderPlaced { ... }) runs after field initialization. It is the natural place to apply defaults for missing fields. This is cleaner than a Jackson @JsonCreator with explicit nullability handling.

Additive changes are safe under one condition: the new field has a meaningful default value. If the new field is required (no valid default), the additive approach does not work. The projection or aggregate must handle the absence of the field, which means the “required” field is not truly required for historical events.

The Test

// FROM SCRATCH
@Test
void v1EventDeserializesToV2Class() throws Exception {
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

    // Simulate a V1 event payload (no promotionCode, no schemaVersion)
    String v1Json = """
        {
            "orderId": "order-1",
            "customerId": "customer-1",
            "lineItems": [{"productId": "p1", "productName": "Widget", "quantity": 2, "unitPrice": 19.99}],
            "total": 39.98,
            "shippingAddress": {"street": "123 Main", "city": "Springfield", "state": "IL", "postalCode": "62701", "country": "US"},
            "occurredAt": "2026-01-15T10:30:00Z"
        }
        """;

    OrderPlaced event = mapper.readValue(v1Json, OrderPlaced.class);
    assertEquals("order-1", event.orderId());
    assertEquals("", event.promotionCode()); // Default applied
    assertEquals(2, event.schemaVersion()); // Default applied
}

Field Renames

The Problem

The OrderPlaced event has a field called shippingAddress. A domain model refactoring renames it to deliveryAddress because the business distinguishes between shipping (warehouse to carrier) and delivery (carrier to customer). Existing events have shippingAddress in their JSON. New events should have deliveryAddress.

The Mechanism

Field renames require an upcaster. The upcaster reads the old JSON, transforms it, and produces JSON that matches the new schema.

// FROM SCRATCH
public interface EventUpcaster {
    boolean canUpcast(String eventType, int fromVersion);
    String upcast(String payload);
    int targetVersion();
}

public class OrderPlacedV1ToV2Upcaster implements EventUpcaster {

    private final ObjectMapper mapper;

    public OrderPlacedV1ToV2Upcaster(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public boolean canUpcast(String eventType, int fromVersion) {
        return "OrderPlaced".equals(eventType) && fromVersion == 1;
    }

    @Override
    public String upcast(String payload) {
        try {
            JsonNode node = mapper.readTree(payload);
            ObjectNode mutable = (ObjectNode) node;

            // Rename shippingAddress -> deliveryAddress
            if (mutable.has("shippingAddress") && !mutable.has("deliveryAddress")) {
                mutable.set("deliveryAddress", mutable.get("shippingAddress"));
                mutable.remove("shippingAddress");
            }

            // Add schema version
            if (!mutable.has("schemaVersion")) {
                mutable.put("schemaVersion", 2);
            }

            // Add promotionCode if missing
            if (!mutable.has("promotionCode")) {
                mutable.put("promotionCode", "");
            }

            return mapper.writeValueAsString(mutable);
        } catch (JsonProcessingException e) {
            throw new UpcastingException("Failed to upcast OrderPlaced v1 to v2", e);
        }
    }

    @Override
    public int targetVersion() {
        return 2;
    }
}

The Upcasting Chain

When multiple schema versions exist, upcasters are chained. A V1 event is upcast to V2, then V2 to V3, and so on. The chain runs in sequence until the event reaches the current version.

// FROM SCRATCH
public class UpcastingChain {

    private final List<EventUpcaster> upcasters;

    public UpcastingChain(List<EventUpcaster> upcasters) {
        this.upcasters = List.copyOf(upcasters);
    }

    public String upcast(String eventType, String payload) {
        int currentVersion = detectVersion(payload);
        String currentPayload = payload;

        for (EventUpcaster upcaster : upcasters) {
            if (upcaster.canUpcast(eventType, currentVersion)) {
                currentPayload = upcaster.upcast(currentPayload);
                currentVersion = upcaster.targetVersion();
            }
        }

        return currentPayload;
    }

    private int detectVersion(String payload) {
        try {
            JsonNode node = new ObjectMapper().readTree(payload);
            if (node.has("schemaVersion")) {
                return node.get("schemaVersion").asInt();
            }
            return 1; // No version field means version 1
        } catch (JsonProcessingException e) {
            return 1;
        }
    }
}

The version detection convention: if the JSON has a schemaVersion field, use it. If not, the event is version 1 (the original schema before versioning was introduced). This convention allows gradual adoption of versioning without modifying existing events.

Integration with the Event Type Registry

// FROM SCRATCH
public class UpcastingEventTypeRegistry {

    private final Map<String, Class<? extends OrderEvent>> typeMap;
    private final ObjectMapper mapper;
    private final UpcastingChain upcastingChain;

    public UpcastingEventTypeRegistry(ObjectMapper mapper, UpcastingChain upcastingChain) {
        this.mapper = mapper;
        this.upcastingChain = upcastingChain;
        this.typeMap = new HashMap<>();
        register("OrderPlaced", OrderPlaced.class);
        register("OrderConfirmed", OrderConfirmed.class);
        // ... other event types
    }

    private void register(String name, Class<? extends OrderEvent> clazz) {
        typeMap.put(name, clazz);
    }

    public OrderEvent deserialize(String eventType, String payload) {
        // Upcast first
        String upcastedPayload = upcastingChain.upcast(eventType, payload);

        Class<? extends OrderEvent> clazz = typeMap.get(eventType);
        if (clazz == null) {
            throw new UnknownEventTypeException("Unknown event type: " + eventType);
        }
        try {
            return mapper.readValue(upcastedPayload, clazz);
        } catch (JsonProcessingException e) {
            throw new EventDeserializationException(
                "Failed to deserialize " + eventType, e
            );
        }
    }
}

Event Splits

The Problem

The OrderPlaced event originally carried both the order details and the initial payment intent. As the system evolved, payment became a separate bounded context. The OrderPlaced event should no longer contain payment information. Instead, two events should be produced: OrderPlaced (order details only) and PaymentIntentCreated (payment information).

The Mechanism

Event splits are the most complex schema change. Unlike additive changes or renames, a split produces multiple events from a single stored event. The upcaster for a split returns a list of event payloads instead of a single payload.

// FROM SCRATCH
public interface SplittingUpcaster {
    boolean canUpcast(String eventType, int fromVersion);
    List<UpcastedEvent> upcast(String eventType, String payload);
}

public record UpcastedEvent(String eventType, String payload) {}

public class OrderPlacedSplitUpcaster implements SplittingUpcaster {

    private final ObjectMapper mapper;

    public OrderPlacedSplitUpcaster(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public boolean canUpcast(String eventType, int fromVersion) {
        return "OrderPlacedWithPayment".equals(eventType) && fromVersion == 1;
    }

    @Override
    public List<UpcastedEvent> upcast(String eventType, String payload) {
        try {
            JsonNode node = mapper.readTree(payload);

            // Extract order fields
            ObjectNode orderNode = mapper.createObjectNode();
            orderNode.put("orderId", node.get("orderId").asText());
            orderNode.put("customerId", node.get("customerId").asText());
            orderNode.set("lineItems", node.get("lineItems"));
            orderNode.put("total", node.get("total").decimalValue());
            orderNode.set("deliveryAddress", node.get("deliveryAddress"));
            orderNode.put("occurredAt", node.get("occurredAt").asText());
            orderNode.put("schemaVersion", 3);

            // Extract payment fields
            ObjectNode paymentNode = mapper.createObjectNode();
            paymentNode.put("orderId", node.get("orderId").asText());
            paymentNode.put("paymentMethod", node.get("paymentMethod").asText());
            paymentNode.put("amount", node.get("total").decimalValue());
            paymentNode.put("occurredAt", node.get("occurredAt").asText());

            return List.of(
                new UpcastedEvent("OrderPlaced", mapper.writeValueAsString(orderNode)),
                new UpcastedEvent("PaymentIntentCreated", mapper.writeValueAsString(paymentNode))
            );
        } catch (JsonProcessingException e) {
            throw new UpcastingException("Failed to split OrderPlacedWithPayment", e);
        }
    }
}

Event splits are dangerous. They change the number of events in a stream, which affects sequence numbers, aggregate version tracking, and projection position tracking. The safest approach for an event split is to introduce a new event type alongside the old one, keep the old type functional with an upcaster, and eventually deprecate the old type after all historical events have been processed through a projection rebuild.

Schema Change Safety Classification

Change TypeSafe Without Upcaster?Requires Upcaster?Requires Migration?
Add optional fieldYes (with defaults)NoNo
Add required fieldNoYes (provide default)No
Rename fieldNoYesNo
Remove fieldYes (if unused)NoNo
Change field typeNoYesSometimes
Split eventNoYes (splitting upcaster)Usually
Merge eventsNoNoYes (new event type)

Safe changes require only Jackson configuration (FAIL_ON_UNKNOWN_PROPERTIES = false, NON_NULL inclusion). Upcaster-required changes need explicit transformation code. Migration-required changes need a new event type with a transition strategy.

The Production Path

// PRODUCTION
@Configuration
public class UpcastingConfig {

    @Bean
    public UpcastingChain upcastingChain(@Qualifier("eventObjectMapper") ObjectMapper mapper) {
        return new UpcastingChain(List.of(
            new OrderPlacedV1ToV2Upcaster(mapper)
            // Add new upcasters here as schema evolves
        ));
    }

    @Bean
    public UpcastingEventTypeRegistry eventTypeRegistry(
            @Qualifier("eventObjectMapper") ObjectMapper mapper,
            UpcastingChain chain) {
        return new UpcastingEventTypeRegistry(mapper, chain);
    }
}

In Axon Framework, upcasting is built into the serialization pipeline. Axon’s EventUpcaster interface provides similar functionality with integration into the aggregate loading and event processing pipelines. Axon maintains an upcaster chain that runs automatically during event deserialization.

The Test

// FROM SCRATCH
@Testcontainers
class SchemaEvolutionTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("schema_test")
        .withInitScript("event_store_schema.sql");

    private EventStore eventStore;
    private UpcastingEventTypeRegistry registry;

    @BeforeEach
    void setUp() {
        var ds = new org.postgresql.ds.PGSimpleDataSource();
        ds.setUrl(postgres.getJdbcUrl());
        ds.setUser(postgres.getUsername());
        ds.setPassword(postgres.getPassword());

        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        eventStore = new EventStore(ds, mapper);

        var chain = new UpcastingChain(List.of(new OrderPlacedV1ToV2Upcaster(mapper)));
        registry = new UpcastingEventTypeRegistry(mapper, chain);
    }

    @Test
    void v1EventsReadThroughV2Projection() throws Exception {
        // Write a V1 event (no promotionCode, shippingAddress instead of deliveryAddress)
        String v1Payload = """
            {
                "orderId": "old-order-1",
                "customerId": "c1",
                "lineItems": [{"productId": "p1", "productName": "Widget", "quantity": 1, "unitPrice": 10.00}],
                "total": 10.00,
                "shippingAddress": {"street": "123 Main", "city": "Springfield", "state": "IL", "postalCode": "62701", "country": "US"},
                "occurredAt": "2026-01-15T10:30:00Z"
            }
            """;

        // Insert directly to simulate old event
        try (Connection conn = getConnection();
             PreparedStatement stmt = conn.prepareStatement(
                 "INSERT INTO event_store (stream_id, sequence_number, event_type, payload, occurred_at) " +
                 "VALUES (?, ?, ?, ?::jsonb, NOW())")) {
            stmt.setString(1, "order-old-order-1");
            stmt.setLong(2, 0);
            stmt.setString(3, "OrderPlaced");
            stmt.setString(4, v1Payload);
            stmt.executeUpdate();
        }

        // Read through upcasting registry
        List<StoredEvent> events = eventStore.readStream("order-old-order-1", 0);
        assertEquals(1, events.size());

        OrderEvent deserialized = registry.deserialize(
            events.get(0).eventType(), events.get(0).payload()
        );
        assertInstanceOf(OrderPlaced.class, deserialized);
        OrderPlaced placed = (OrderPlaced) deserialized;
        assertEquals("old-order-1", placed.orderId());
        assertEquals("", placed.promotionCode()); // Default from upcaster
    }

    @Test
    void upcastingChainProcessesMultipleVersions() {
        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        var chain = new UpcastingChain(List.of(new OrderPlacedV1ToV2Upcaster(mapper)));

        String v1Payload = """
            {"orderId": "x", "shippingAddress": {"street": "1"}, "occurredAt": "2026-01-01T00:00:00Z"}
            """;

        String result = chain.upcast("OrderPlaced", v1Payload);
        assertTrue(result.contains("deliveryAddress"));
        assertFalse(result.contains("shippingAddress"));
    }

    private Connection getConnection() throws SQLException {
        var ds = new org.postgresql.ds.PGSimpleDataSource();
        ds.setUrl(postgres.getJdbcUrl());
        ds.setUser(postgres.getUsername());
        ds.setPassword(postgres.getPassword());
        return ds.getConnection();
    }
}

The first test is the critical validation. It writes a genuine V1 event payload to the event store, reads it through the upcasting registry, and verifies that the deserialized event has the V2 schema. This test must pass after every upcaster change. If it fails, the upcaster is broken and production event loading will fail.

This chapter established event schema evolution as a first-class engineering concern. Part V covers the operational reality: storage growth, debugging, and observability.