Snapshots
As the event history of an aggregate grows, replaying every event on each findById call becomes
increasingly expensive.
Snapshots bound this cost: the framework periodically serializes the full aggregate state as JSON and
stores it alongside the events.
On the next load, replay starts from the latest snapshot rather than from the beginning — only the
events recorded after the snapshot need to be applied.
Snapshots are opt-in and enabled by default; they become active as soon as a
spring-ddd-starter-eventsourcing-jdbc (or a full bundle such as spring-ddd-starter-jdbc) is on
the classpath.
Global threshold
A snapshot is written after every N new events since the previous snapshot (or since the aggregate was first saved when no snapshot exists yet). The threshold is configured with:
spring.ddd.eventsourcing.snapshot.threshold=100
The default is 100.
To disable snapshots application-wide, set:
spring.ddd.eventsourcing.snapshot.enabled=false
Per-aggregate override
Override the threshold for a specific aggregate type with the snapshotPolicy attribute of
@EventSourced:
import de.dwittkoetter.ddd.annotation.Aggregate
import de.dwittkoetter.ddd.eventsourcing.EventSourced
import de.dwittkoetter.ddd.eventsourcing.SnapshotPolicy
import de.dwittkoetter.ddd.eventsourcing.aggregate.EventSourcingAggregateRoot
@Aggregate(namespace = "banking")
@EventSourced(snapshotPolicy = SnapshotPolicy(threshold = 10))
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
private set
}
SnapshotPolicy(threshold = 10) causes a snapshot after every 10 new events, regardless of the
global property.
To explicitly opt back into the global value, pass SnapshotPolicy.USE_GLOBAL (the value -1,
which is also the annotation default):
@EventSourced(snapshotPolicy = SnapshotPolicy(threshold = SnapshotPolicy.USE_GLOBAL))
|
When |
Snapshot versioning
Over time, the persistent state of an aggregate may change in a way that makes old snapshots
incompatible with the current class definition — for example when a required field is added or an
existing field is renamed.
@SnapshotRevision tracks these changes.
Place @SnapshotRevision(N) on the aggregate class to declare its current schema revision:
import de.dwittkoetter.ddd.annotation.Aggregate
import de.dwittkoetter.ddd.annotation.SnapshotRevision
import de.dwittkoetter.ddd.eventsourcing.aggregate.EventSourcingAggregateRoot
@Aggregate(namespace = "banking")
@SnapshotRevision(2)
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
private set
var balance: Money = Money.ZERO
private set
var overdraftLimit: Money = Money.ZERO
private set
}
Rules:
-
Absent ⇒ revision 0. An aggregate whose snapshot shape has never changed needs no annotation.
-
First evolved revision is 1. Increment by exactly one for each breaking change.
-
Removing a field is safe. Jackson ignores unknown JSON properties during deserialization — no revision bump is needed.
-
Breaking changes (adding a required field, renaming a field, changing a field’s type) must be accompanied by a revision bump and a matching
@SnapshotUpcaster(see below).
The revision is stamped onto every snapshot row at write time. At startup the framework validates that no upcaster step is missing for any declared revision.
Handling breaking changes with @SnapshotUpcaster
When a snapshot’s stored revision is older than the aggregate class’s @SnapshotRevision, the
framework applies the registered upcasters in order before deserializing — avoiding a full event
replay.
Annotate a method with @SnapshotUpcaster inside any Spring @Component.
The method must accept a Jackson ObjectNode (the stored payload at fromRevision) and return an
ObjectNode (the payload one revision higher):
import tools.jackson.databind.node.ObjectNode
import de.dwittkoetter.ddd.eventsourcing.upcasting.SnapshotUpcaster
import org.springframework.stereotype.Component
@Component
class BankAccountSnapshotUpcasters {
// revision 1 → 2: seed the new overdraftLimit field
@SnapshotUpcaster(aggregateType = "banking.BankAccount", fromRevision = 1)
fun v1ToV2(node: ObjectNode): ObjectNode = node.apply {
put("overdraftLimit", "0")
}
}
aggregateType is the stable type key "$namespace.$name" — the same value stored in the event
log (e.g. "banking.BankAccount" for @Aggregate(namespace = "banking") class BankAccount).
What happens on findById when the stored revision does not match the current class revision:
| Stored revision | Behaviour |
|---|---|
Equal to current |
Snapshot used as-is. |
Older than current |
Upcasters are applied in order from the stored revision up to the current revision, then the result is deserialized — no full event replay. |
Newer than current |
Snapshot is ignored with a warning; the aggregate is rebuilt from events. There is no snapshot downcaster. |
Snapshot upcasters are strictly 1→1: unlike event upcasters, there is no split, no drop, and no type rename — an aggregate has exactly one snapshot schema at any revision.
Startup validation fails with a clear error if any upcaster step in the chain (revision 0→1, 1→2, …) is missing.
Snapshot upcasters can be exercised in isolation with the snapshotUpcasterTest { } fixture and the @UpcasterTest JUnit 5 extension from spring-ddd-eventsourcing-test — see Testing schema evolution.