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 the full-stack bundle spring-ddd-starter-jpa) 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:
@Aggregate(namespace = "banking")
@EventSourced(snapshotPolicy = SnapshotPolicy(threshold = 10))
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
}
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:
@Aggregate(namespace = "banking")
@SnapshotRevision(2)
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
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):
@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 store (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.