Snapshots
In a pure event-sourced system every aggregate is rebuilt from scratch by replaying all of its events on every load. That is correct, simple, and bounded — until it isn't. Long-lived aggregates that accumulate hundreds or thousands of events make GetAggregate slow enough to dominate request latency.
Snapshots are the standard escape hatch: periodically the framework writes a serialised copy of the aggregate's state at a specific version. The next GetAggregate call restores from the snapshot and replays only the events recorded after that version.
In v3, snapshots are an opt-in framework feature configurable at the Facade level — no per-aggregate code changes required.
When to enable snapshots
Use snapshots when at least one of the following is true:
- An aggregate type tends to accumulate many events (10s+ per instance).
GetAggregateis on a hot path (per-request authorization checks, frequent re-loads in reactors, etc.).- You are running on Postgres in production and want to keep aggregate-load latency stable as the event store grows.
For a small toy application or a domain where aggregates are short-lived, snapshots are pure overhead — leave them off.
Configuration
Pass a SnapshotStore and (optionally) a snapshot interval to the Facade at construction time:
import (
"github.com/gradientzero/comby/v3"
snapshotPostgres "github.com/gradientzero/comby-store-postgres"
)
ss := snapshotPostgres.NewSnapshotStorePostgres(host, port, user, pass, db)
fc, err := comby.NewFacade(
// ... your other options
comby.FacadeWithSnapshotStore(ss),
comby.FacadeWithSnapshotInterval(100), // snapshot every 100 events
)
if err != nil {
panic(err)
}The interval is the number of events between two snapshots for a given aggregate. A snapshot every 100 events means a worst-case replay of 99 events plus one snapshot read on GetAggregate. Tune this number against your aggregate sizes — for highly active aggregates 100 is a reasonable default; for very large aggregates 25–50 may be better.
If you do not pass FacadeWithSnapshotStore, the framework keeps the v2 behaviour: full event replay every time.
Available stores
| Backend | Constructor | Notes |
|---|---|---|
| In-memory | comby.NewSnapshotStoreMemory() | Built-in. Test/dev only — state is lost on process exit. |
| SQLite | snapshotSqlite.NewSnapshotStoreSQLite(path) | Persistent file-based. Same store package as comby-store-sqlite. |
| Postgres | snapshotPostgres.NewSnapshotStorePostgres(host, port, user, password, dbName) | Production-grade. Supports the same RLS app-role option as the event/command stores when activated. |
| Redis | snapshotRedis.NewSnapshotStoreRedis(addr, password, db) | High-throughput, eventual consistency. |
See Snapshot Store reference for the full interface and per-backend configuration knobs (connection pool sizes, RLS, etc.).
How it works
When GetAggregate(ctx, aggregateUuid) is called and a SnapshotStore is configured:
The snapshot version stored on disk acts as a high-water mark. The aggregate repository:
- Loads the latest snapshot (if any).
- Reads only events with
version > snapshot.versionfrom the event store. - Applies them to the snapshot-restored aggregate.
Result: load cost goes from O(n) total events to O(snapshotInterval) events at most.
Writing snapshots
Snapshot writes are triggered automatically inside AggregateRepository.SaveAggregate whenever the new aggregate version crosses a multiple of the configured interval. There is no manual snapshot-trigger API — by design, so you can't accidentally produce divergent snapshots.
If you need to invalidate a snapshot (e.g. you migrated event data and the on-disk snapshot is now wrong), SnapshotStore.Delete(ctx, aggregateUuid) removes it. The next GetAggregate will then perform a full replay and immediately write a fresh snapshot.
RLS interaction (Postgres only)
If you have enabled Row Level Security on the postgres event/command stores, also enable it on the snapshot store:
ss := snapshotPostgres.NewSnapshotStorePostgres(host, port, user, pass, db,
snapshotPostgres.SnapshotStorePostgresWithRLSAppRole("comby_app", "<password>"),
)The snapshot store uses the same dual-pool routing pattern as the other postgres stores: tenant-scoped Save runs on the NOBYPASSRLS app role inside SET LOCAL app.tenant_uuid; cross-tenant GetLatest and admin operations stay on the owner pool. See the RLS docs for the full pattern.
See also
- Snapshot Store reference — interface and implementation details
- Aggregate Repository — how
GetAggregateconsumes snapshots - Row Level Security — RLS option on the snapshot store