Skip to content

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).
  • GetAggregate is 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:

go
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

BackendConstructorNotes
In-memorycomby.NewSnapshotStoreMemory()Built-in. Test/dev only — state is lost on process exit.
SQLitesnapshotSqlite.NewSnapshotStoreSQLite(path)Persistent file-based. Same store package as comby-store-sqlite.
PostgressnapshotPostgres.NewSnapshotStorePostgres(host, port, user, password, dbName)Production-grade. Supports the same RLS app-role option as the event/command stores when activated.
RedissnapshotRedis.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:

  1. Loads the latest snapshot (if any).
  2. Reads only events with version > snapshot.version from the event store.
  3. 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:

go
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