Schema evolution
Event payloads stored in the event store are immutable, but the classes that represent them change over time. Schema evolution handles two directions: upcasting transforms an old stored payload to the current shape on read, and downcasting tolerates a payload written by a newer version of the application than the code currently running. Both happen transparently before deserialization — callers never opt in.
Upcasting
Declaring the current revision
Every stored event carries its revision in the event_revision column.
Annotate an event class with @EventRevision to declare its current schema revision:
@EventRevision(2)
@DomainEvent(namespace = "banking", name = "AccountOpened")
data class AccountOpened(
val accountId: AccountId,
val owner: String, // added at revision 1
val currency: String, // added at revision 2
)
Rules:
-
Absent ⇒ revision 0. An event whose JSON shape has never changed needs no annotation.
-
First evolved revision is 1. Increment by exactly one for each breaking change (field add, remove, rename, or event-type rename).
-
Revision stamps are write-time. Rows already in the event store are never rewritten; upcasters convert them on read.
Writing an upcaster
Annotate a method inside any Spring @Component with @EventUpcaster.
The method receives an ObjectNode (the stored payload at fromRevision) and returns the payload one revision higher:
@Component
class AccountOpenedUpcasters {
// revision 0 → 1: field was absent; seed a default owner
@EventUpcaster(eventType = "banking.AccountOpened", fromRevision = 0)
fun v0ToV1(node: ObjectNode): ObjectNode =
node.putDefault("owner", "unknown")
// revision 1 → 2: rename `ccy` to `currency`
@EventUpcaster(eventType = "banking.AccountOpened", fromRevision = 1)
fun v1ToV2(node: ObjectNode): ObjectNode =
node.renameField("ccy", "currency")
}
-
The
eventTypestring is"$namespace.$name"— the same value stored in the event store (e.g."banking.AccountOpened"for@DomainEvent(namespace = "banking", name = "AccountOpened")). -
Steps chain automatically: a row at revision 0 with both
0→1and1→2registered is delivered as the revision-2 shape. -
Event types with no registered upcaster cost nothing on replay — no JSON is parsed.
-
A upcaster method may declare a nullable return type (
ObjectNode?); returningnulldrops the event entirely without raising an error. -
To rename an event type, set
toEventTypeon the annotation. The old type’s class may no longer exist; that is fine, because upcasters are keyed on the type string, not the class. -
An upcaster, like a downcaster, may reshape anything but the event’s key: the value of its
@AggregateIdor@MessageKeystays what it was stored with — see Splitting and dropping events (1→N).
Startup validation
If an event class declares @EventRevision(n) (n > 0) but the registered upcasters do not form an unbroken chain from revision 0 up to n, the application fails to start with a message naming the missing step.
Duplicate (eventType, fromRevision) pairs also fail fast.
A revision bump with no real field change still requires a pass-through upcaster — the chain must be complete from revision 0.
The check covers the event classes in the application’s auto-configuration packages (by default, the package of its @SpringBootApplication class and everything below it) and in the packages of every @EventSourcedScan, including classes nested in another type, such as the members of a sealed hierarchy.
An @EventRevision class in a test source set under those packages is checked too: give it a complete chain, or keep it in a package the application does not scan.
Read-time check
What startup validation cannot see is still caught when an event is read.
A stored event that its upcasters leave below its class’s current revision is never deserialised from the older shape: the read throws MissingEventUpcasterException, naming the event type, the revision the upcasters reached, and the current one.
An aggregate load then fails. A projection or route consumer does not retry the message: its error handler decides at once.
One that an upcaster carries past the current revision is treated as a revision newer than the running code, as described under Downcasting.
This covers event classes outside the scanned packages, the outputs of a split, and the capture topic’s messages alike.
Splitting and dropping events (1→N)
An @EventUpcaster method may return List<UpcastEvent> instead of an ObjectNode.
Each produced UpcastEvent carries its own eventType + revision and re-enters the upcaster chain:
@Component
class AccountOpenedSplitter {
@EventUpcaster(eventType = "banking.AccountOpenedWithDeposit", fromRevision = 0)
fun split(node: ObjectNode): List<UpcastEvent> {
val accountNode = node.deepCopy().apply { remove("initialDeposit") }
val depositNode = node.deepCopy().apply {
propertyNames().toList()
.filter { it != "accountId" && it != "initialDeposit" }
.forEach { remove(it) }
}
return listOf(
UpcastEvent("banking.AccountOpened", revision = 2, payload = accountNode),
UpcastEvent("banking.MoneyDeposited", revision = 0, payload = depositNode),
)
}
}
-
Drop an obsolete event by returning
emptyList(). A method whose return type is non-null may also returnnullonly when the return type is declared nullable — a non-null method returningnullfails loud at read time to prevent an accidental null from silently deleting events. -
revisionis required: set it to the produced type’s current revision to emit a finished event, or to an intermediate revision to reuse that type’s existing upcasters. -
Outputs are produced in declared, depth-first order — handlers may rely on it.
-
Every produced event keeps the key of the event it was read from — the value of its
@AggregateIdor@MessageKey— or its type declares no key at all. A read model materialized for one account reads only the events stored under that account, so an event moved to another account would reach that account’s read model in a full replay and be missing from it when it is materialized on its own. AMoneyTransferredstored for the sending account may be split into theMoneyWithdrawnit booked there, but not into aMoneyDepositedon the receiving account; record the deposit as the receiving account’s own event instead. A projection or other consumer reading an event whose upcast changes its key fails on it, naming the event and both keys: the message is retried under the delivery budget, then the error handler decides.
Retire an event type with an upcaster that drops it, never by deleting its class alone.
A stored event whose class is gone is unreadable, and under the default HaltErrorHandler it stops every projection and route consumer that reads its topic — even those that never listen to it — and every saga consumer that reads it (see Event delivery).
A materialization of an ad-hoc projection fails on it too, whatever the projection listens to.
An upcaster that drops the retired type, as described under Splitting and dropping events, removes the event on the way in, and nothing reads it again.
|
Startup chain validation traces 1→1 lineages only; it cannot statically see into a split’s outputs.
The read-time check still refuses an output left below its current revision, and |
Field-retype helpers
spring-ddd-eventsourcing ships extension functions on ObjectNode for common edits inside an upcaster body:
| Helper | Effect |
|---|---|
|
Renames a field; no-op when absent. |
|
Removes a field if present. |
|
Sets a field only when absent or JSON |
|
Replaces a field’s value with the result of a transform; no-op when absent or |
|
Retypes a string token (e.g. |
|
Retypes a string token (e.g. |
|
Retypes a numeric token to its string form. |
Limitations
On-read upcasting supports field add/remove/rename, event-type rename, and 1→N split/drop. Not supported: N→1 merge or rewriting the stored events.
Aggregate snapshots have their own strictly 1→1 upcasting — see Snapshot versioning.
Downcasting
In a rolling deploy, an instance running new code may write an event at a revision that an instance still running old code does not recognize.
Purely additive changes (a new optional field) are tolerated automatically — unknown JSON fields are ignored during deserialization.
For non-additive changes, declare an @EventDowncaster.
A downcaster is the reverse of an upcaster: it reads a stored payload at fromRevision (a future revision) and returns the payload at fromRevision - 1.
Downcasters are strictly 1→1; there is no list form.
@Component
class AccountOpenedDowncasters {
// future revision 3 added an `extra` field the revision-2 model doesn't carry; drop it on the way down
@EventDowncaster(eventType = "banking.AccountOpened", fromRevision = 3)
fun v3ToV2(node: ObjectNode): ObjectNode =
node.removeField("extra")
}
-
fromRevisionis the future revision being read; the method producesfromRevision - 1. -
To reverse an event-type rename, set
toEventTypeto the old type name. -
Two-phase deploy: old code cannot know a future revision, so ship the downcaster-aware release before rolling out the non-additive schema change.
Behavior when no downcaster bridges the gap
| Read path | Behavior |
|---|---|
Aggregate reconstruction |
Throws |
Projections and route consumers |
Retried under the delivery budget, then decided by the consumer’s error handler. An instance that already runs the new code reads the message, so a rolling deploy that ends within the budget needs nothing; otherwise, after |
Validation is read-time, not startup: the framework cannot bound future revisions statically, so completeness is enforced only when a future revision appears.
Snapshots and rolling deploys
Snapshots have no downcaster.
A stored snapshot whose @SnapshotRevision is newer than this code is ignored (with a warning) and the aggregate is rebuilt from events instead — so an old instance remains correct during a rolling deploy without needing a snapshot downcaster.
Upcasters and downcasters are tested with the schema-evolution test DSL in spring-ddd-eventsourcing-test — see Testing schema evolution.