Schema evolution
Event payloads stored in the event log 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:
import de.dwittkoetter.ddd.annotation.DomainEvent
import de.dwittkoetter.ddd.annotation.EventRevision
@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 log are never rewritten; upcasters migrate 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:
import tools.jackson.databind.node.ObjectNode
import de.dwittkoetter.ddd.eventsourcing.upcasting.EventUpcaster
import de.dwittkoetter.ddd.eventsourcing.upcasting.putDefault
import de.dwittkoetter.ddd.eventsourcing.upcasting.renameField
import org.springframework.stereotype.Component
@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 log (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.
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.
Unknown event types on projection replay
After upcasting, an event type that no projection handler recognizes is skipped — a projection does not need to handle every event in the store. A failing upcaster, however, is never swallowed: it surfaces as an exception on both aggregate reconstruction and projection replay.
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:
import tools.jackson.databind.node.ObjectNode
import de.dwittkoetter.ddd.eventsourcing.upcasting.EventUpcaster
import de.dwittkoetter.ddd.eventsourcing.upcasting.UpcastEvent
import org.springframework.stereotype.Component
@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.
|
Startup chain validation traces 1→1 lineages only; it cannot statically see into a split’s outputs. |
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 stored-data migration.
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.
import tools.jackson.databind.node.ObjectNode
import de.dwittkoetter.ddd.eventsourcing.upcasting.EventDowncaster
import de.dwittkoetter.ddd.eventsourcing.upcasting.removeField
import org.springframework.stereotype.Component
@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 |
Projection replay |
The consumer stalls and self-heals once an upgraded instance acquires the lease (up to one lease duration after the old instance terminates); the event is never skipped. |
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.