Event Schema Evolution: Adding Fields, Renaming Events, and the Upcasting Chain
Event Schema Evolution
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 Type | Safe Without Upcaster? | Requires Upcaster? | Requires Migration? |
|---|---|---|---|
| Add optional field | Yes (with defaults) | No | No |
| Add required field | No | Yes (provide default) | No |
| Rename field | No | Yes | No |
| Remove field | Yes (if unused) | No | No |
| Change field type | No | Yes | Sometimes |
| Split event | No | Yes (splitting upcaster) | Usually |
| Merge events | No | No | Yes (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.