Snapshot
Snapshots save aggregate-state checkpoints to reduce event replay. With the recommended all strategy, the same data also serves as the default materialized current-state query store through SnapshotQueryService and Wow's built-in query routes.
Snapshot Mechanism
In event sourcing, the state of an aggregate root is reconstructed by replaying all historical events. As the number of events increases, replaying all events becomes slower and slower. The snapshot mechanism solves this problem by periodically saving the current state of the aggregate root.
interface Snapshot<S : Any> : ReadOnlyStateAggregate<S>, SnapshotTimeCapable
data class SimpleSnapshot<S : Any>(
override val delegate: ReadOnlyStateAggregate<S>,
override val snapshotTime: Long = System.currentTimeMillis()
) : Snapshot<S>Snapshot Loading Flow
When loading an aggregate, the snapshot store is consulted first. If a snapshot exists, only events after the snapshot version need to be replayed.
Snapshot Strategies
Snapshot strategies react to each state event and decide whether to persist a new snapshot. The strategy contract is reactive and processes one StateEventExchange at a time instead of returning a boolean predicate:
interface SnapshotStrategy {
fun onEvent(stateEventExchange: StateEventExchange<*>): Mono<Void>
}The Wow framework provides the following built-in strategies:
Version Offset Strategy (VersionOffset)
Creates a snapshot when the difference between the aggregate root version and the last snapshot version reaches the configured threshold. The strategy reads the stored version via SnapshotStore.getVersion() and only saves when the offset is met, so snapshot frequency is independent of concurrent state events.
class VersionOffsetSnapshotStrategy(
private val versionOffset: Int = DEFAULT_VERSION_OFFSET, // 5
private val snapshotStore: SnapshotStore
) : SnapshotStrategyAll Strategy (All)
Saves a snapshot for every state event.
class SimpleSnapshotStrategy(
private val snapshotStore: SnapshotStore
) : SnapshotStrategyNo Operation Strategy (NoOp)
Does not create any snapshots. NoOp is nested inside the SnapshotStrategy interface as a companion object:
interface SnapshotStrategy {
// ...
companion object NoOp : SnapshotStrategy {
override fun onEvent(stateEventExchange: StateEventExchange<*>): Mono<Void> = Mono.empty()
}
}Snapshot Lifecycle
Snapshot Store
The snapshot store is responsible for storing and retrieving snapshots. Batch aggregate ID scanning belongs to EventStore.scanAggregateId(...), not to the snapshot store.
interface SnapshotStore : Named, AutoCloseable {
fun <S : Any> load(aggregateId: AggregateId): Mono<Snapshot<S>>
fun <S : Any> save(snapshot: Snapshot<S>): Mono<Void>
fun getVersion(aggregateId: AggregateId): Mono<Int>
}SnapshotStore extends AutoCloseable. The default close() is a no-op, but storage-backed implementations (and batching wrappers) release workers and flush partial windows on close; Spring closes configured beans through their normal lifecycle.
SnapshotStore.save() atomically maintains the latest snapshot for each aggregate. A candidate whose aggregate version is greater than or equal to the stored version replaces the complete stored snapshot; only a lower-version candidate is a no-op. Allowing equal-version replacement lets snapshot regeneration repair state without changing the aggregate version. Storage implementations must enforce the comparison in the same atomic operation as the write to prevent out-of-order state events from regressing the snapshot.
In-Memory Implementation
class InMemorySnapshotStore : SnapshotStore {
private val snapshots = ConcurrentHashMap<AggregateId, ObjectNode>()
override fun <S : Any> load(aggregateId: AggregateId): Mono<Snapshot<S>> =
Mono.defer {
Mono.justOrEmpty(snapshots[aggregateId]?.toObject<Snapshot<S>>())
}
override fun <S : Any> save(snapshot: Snapshot<S>): Mono<Void> =
Mono.fromRunnable {
val candidate = snapshot.toJsonNode<ObjectNode>()
val candidateVersion = candidate[MessageRecords.VERSION].asInt()
snapshots.compute(snapshot.aggregateId) { _, stored ->
if (
stored == null ||
candidateVersion >= stored[MessageRecords.VERSION].asInt()
) {
candidate
} else {
stored
}
}
}
}Supported Backends
| Backend | Module | Snapshot storage | Dynamic snapshot query |
|---|---|---|---|
| In-memory | wow-core | Development/testing | No built-in query factory |
| MongoDB | wow-mongo | Production-ready | Yes |
| Redis | wow-redis | Production-ready | No built-in query factory |
| Elasticsearch | wow-elasticsearch | Production-ready | Yes |
Snapshot Processing Flow
- State Event Publishing: When aggregate root state changes, publish state events
- Strategy Evaluation: Snapshot strategy evaluates whether a snapshot needs to be created
- Snapshot Creation: If needed, create a snapshot of the current state
- Snapshot Storage: Save the snapshot to the snapshot store
Configuration
wow:
eventsourcing:
snapshot:
enabled: true # Whether to enable snapshots
strategy: all # Snapshot strategy (all, version_offset)
storage: mongo # Snapshot storage backend (mongo, redis, elasticsearch, in_memory)| Property | Default | Description |
|---|---|---|
wow.eventsourcing.snapshot.enabled | true | Enable latest snapshots |
wow.eventsourcing.snapshot.strategy | all | Snapshot strategy (all or version_offset) |
wow.eventsourcing.snapshot.version-offset | 5 | Version offset threshold (only used by version_offset) |
wow.eventsourcing.snapshot.storage | mongo | Snapshot storage backend (shared StorageType enum) |
Snapshots as the Default Read Model
Use strategy: all by default. SimpleSnapshotStrategy materializes the state produced by every state event, making the snapshot store a real-time current-state query store after the SNAPSHOT stage completes, as well as an aggregate-loading checkpoint. For standard queries over one aggregate type, this removes the need to write a projection that duplicates aggregate state.
| Strategy | Stored state | Query consequence | Recommendation |
|---|---|---|---|
all | Every processed state event updates the latest snapshot | Queries read the latest materialized aggregate state after snapshot processing completes | Recommended |
version_offset | A snapshot is written only after the version gap reaches version-offset | Snapshot queries can lag behind the aggregate | Use only when staleness is accepted or another read model serves current queries |
When WebFlux support is enabled, Wow generates standard snapshot query endpoints for each aggregate:
| Query shape | Route suffix | Result |
|---|---|---|
| Count | /snapshot/count | Number of matching snapshots |
| List | /snapshot/list and /snapshot/list/state | Bounded snapshot or state list |
| Paged | /snapshot/paged and /snapshot/paged/state | Paged snapshots or states |
| Single | /snapshot/single and /snapshot/single/state | One snapshot or state |
These routes are backed by the same SnapshotQueryService contract used by the Query DSL, and Spring registers a typed <aggregate>.SnapshotQueryService bean for each aggregate. Applications therefore do not need to hand-write query API endpoints for these standard shapes (SnapshotQueryService.kt:30-61, SnapshotQueryServiceRegistrar.kt:28-61, SnapshotRouteContributor.kt:59-281).
Query capability and consistency boundaries
A query-capable backend is required. MongoDB and Elasticsearch provide SnapshotQueryServiceFactory; a custom backend must provide the matching binding. Redis and in-memory snapshot stores support persistence and loading but do not by themselves provide dynamic snapshot queries. Keep tenant/owner filtering, authorization, and indexes explicit. Snapshot processing consumes state events asynchronously. With strategy: all and the query service bound to the same backend, a caller that requires read-after-write visibility must wait for the SNAPSHOT command stage. The stage only proves snapshot processing completed; version_offset can complete without writing when its threshold is not met. The event stream remains the source of truth.
Continue to use a projection when the read model joins multiple aggregates, needs a denormalized schema that differs from aggregate state, feeds analytics, or synchronizes an external system.
Aggregate Loading Optimization
Aggregate loading should reuse the framework's StateAggregateRepository instead of manually composing SnapshotStore, EventStore, and event replay in application code:
val aggregateId = namedAggregate.aggregateId(id = orderId, tenantId = tenantId)
val aggregate: Mono<StateAggregate<OrderState>> =
stateAggregateRepository.load(aggregateId)When the latest version is requested, EventSourcingStateAggregateRepository first tries the snapshot. It then reads EventStore from stateAggregate.expectedNextVersion and applies each incremental stream through stateAggregate.onSourcing(eventStream). Historical-version queries do not use the latest snapshot.
Performance Impact
allStrategy: Once snapshot processing completes, the latest snapshot already contains the state produced by the latest state eventversion_offsetStrategy: Aggregate loading replays only events after the last snapshot, bounded by the configured offset- Snapshots Disabled: Every load requires replaying all historical events
- Storage Cost: Requires additional storage space to save snapshot data
For example, explicitly choosing strategy: version_offset with version-offset: 50 limits aggregate loading to at most 49 replayed events, but the same lag also applies to direct snapshot queries. The recommended all strategy favors a current query store over reducing snapshot writes.
Best Practices
- Prefer
all: Use the latest snapshot as the default current-state read model. - Reuse the query service and routes: Do not duplicate aggregate state in a projection or write a controller for standard single/list/paged/count queries.
- Select a query-capable backend: Use MongoDB, Elasticsearch, or a custom
SnapshotQueryServiceFactorywhen dynamic queries are required. - Design query safety and performance: Verify authorization, tenant/owner filters, indexes, and query plans with production-like data.
- Define read-after-write behavior: With
alland the same query-capable backend, wait forSNAPSHOTwhen the response must be visible through snapshot queries. - Treat
version_offsetas an explicit trade-off: Use it only after accepting query staleness or providing another current-state read model.
SnapshotStore currently has no generic deletion API. Physical cleanup, when required, must be designed and verified for the selected backend rather than treated as a Wow lifecycle capability.
Related Topics
- Production Best Practices — Apply snapshots as the default query store in a complete production checklist
- Query Service — Build filters and use the generated snapshot query endpoints
- Projection — Build cross-aggregate or purpose-specific read models