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 eventType string 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→1 and 1→2 registered 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?); returning null drops the event entirely without raising an error.

  • To rename an event type, set toEventType on 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 return null only when the return type is declared nullable — a non-null method returning null fails loud at read time to prevent an accidental null from silently deleting events.

  • revision is 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

renameField(from, to)

Renames a field; no-op when absent.

removeField(name)

Removes a field if present.

putDefault(name, value)

Sets a field only when absent or JSON null; value may be String, Long, or Boolean.

retype(field) { …​ }

Replaces a field’s value with the result of a transform; no-op when absent or null.

stringToLong(field)

Retypes a string token (e.g. "42") to a JSON integer.

stringToBigDecimal(field)

Retypes a string token (e.g. "9.95") to a JSON decimal.

numberToString(field)

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")
}
  • fromRevision is the future revision being read; the method produces fromRevision - 1.

  • To reverse an event-type rename, set toEventType to 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 FutureEventRevisionException — aggregate load fails loud.

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.