Snapshots
Replaying events gets expensive as aggregates accumulate history. Snapshots checkpoint aggregate state, allowing you to skip replaying old events.
How Snapshots Work
Instead of replaying all 1050 events, load the snapshot and replay only the 50 new ones.
Enabling Snapshots
Use with_snapshots() when creating the repository:
use sourcery::{Repository, snapshot::inmemory, store::inmemory as event_store};
let event_store = event_store::Store::new();
let snapshot_store = inmemory::Store::every(100);
let repository = Repository::new(event_store)
.with_snapshots(snapshot_store);
Snapshot Policies
snapshot::inmemory::Store provides three policies:
Always
Save a snapshot after every command:
let snapshots = inmemory::Store::always();
Use for: Aggregates with expensive replay, testing.
Every N Events
Save after accumulating N events since the last snapshot:
let snapshots = inmemory::Store::every(100);
Use for: Production workloads balancing storage vs. replay cost.
Never
Never save (load-only mode):
let snapshots = inmemory::Store::never();
Use for: Read-only replicas, debugging.
The SnapshotStore Trait
pub trait SnapshotStore<Id: Sync>: Send + Sync {
/// Position type for tracking snapshot positions.
///
/// Must match the `EventStore::Position` type used in the same repository.
type Position: Send + Sync;
/// Error type for snapshot operations.
type Error: std::error::Error + Send + Sync + 'static;
/// Load the most recent snapshot for an aggregate.
///
/// Returns `Ok(None)` if no snapshot exists.
///
/// # Errors
///
/// Returns an error if the underlying storage fails.
fn load<T>(
&self,
kind: &str,
id: &Id,
) -> impl std::future::Future<Output = Result<Option<Snapshot<Self::Position, T>>, Self::Error>> + Send
where
T: DeserializeOwned;
/// Whether to store a snapshot, with lazy snapshot creation.
///
/// The repository calls this after successfully appending new events,
/// passing `events_since_last_snapshot` and a `create_snapshot`
/// callback. Implementations may decline without invoking
/// `create_snapshot`, avoiding unnecessary snapshot creation cost
/// (serialisation, extra I/O, etc.).
///
/// Returning [`SnapshotOffer::Stored`] indicates that the snapshot was
/// persisted. Returning [`SnapshotOffer::Declined`] indicates that no
/// snapshot was stored.
///
/// # Errors
///
/// Returns [`OfferSnapshotError::Create`] if `create_snapshot` fails.
/// Returns [`OfferSnapshotError::Snapshot`] if persistence fails.
fn offer_snapshot<CE, T, Create>(
&self,
kind: &str,
id: &Id,
events_since_last_snapshot: u64,
create_snapshot: Create,
) -> impl std::future::Future<
Output = Result<SnapshotOffer, OfferSnapshotError<Self::Error, CE>>,
> + Send
where
CE: std::error::Error + Send + Sync + 'static,
T: Serialize,
Create: FnOnce() -> Result<Snapshot<Self::Position, T>, CE> + Send;
}
The repository calls offer_snapshot after successfully appending new events. Implementations may decline without invoking create_snapshot, avoiding unnecessary snapshot serialisation.
The Snapshot Type
pub struct Snapshot<Pos, Data> {
pub position: Pos,
pub data: Data,
}
The position indicates which event this snapshot was taken after. When loading, only events after this position need to be replayed.
Projection Snapshots
Projection snapshots use the same store and are keyed by (P::KIND, instance_id).
Use load_projection_with_snapshot on a repository configured with snapshots:
#[derive(Default, Serialize, Deserialize, sourcery::Projection)]
#[projection(kind = "loyalty.summary")]
struct LoyaltySummary {
total_earned: u64,
}
// (ProjectionFilters impl defines filters and associated types)
let repo = Repository::new(store).with_snapshots(inmemory::Store::every(100));
let summary: LoyaltySummary = repo
.load_projection_with_snapshot::<LoyaltySummary>(&instance_id)
.await?;
Singleton projections use InstanceId = () and pass &(). Instance projections
pass their instance identifier.
Projection snapshots require a globally ordered store (S: GloballyOrderedStore)
and P: Serialize + DeserializeOwned.
When to Snapshot
| Aggregate Type | Recommendation |
|---|---|
| Short-lived (< 100 events) | Skip snapshots |
| Medium (100-1000 events) | Every 100-500 events |
| Long-lived (1000+ events) | Every 100 events |
| High-throughput | Every N events, tuned to your SLA |
Implementing a Custom Store
For production, implement SnapshotStore with your database. See Custom Stores for a complete guide.
Snapshot Invalidation
Snapshots are tied to your aggregate’s serialised form. When you change the struct:
- Add fields — Use
#[serde(default)]for backwards compatibility - Remove fields — Old snapshots still deserialise (extra fields ignored)
- Rename fields — Use
#[serde(alias = "old_name")] - Change types — Old snapshots become invalid; delete them
For major changes, delete old snapshots and let them rebuild from events.
Next
Event Versioning — Evolving event schemas over time