Save, Load, Per-Aggregate Compaction
Where the Event Store is the append-only log, the snapshot store is the per-aggregate cache that makes long streams tractable. Save a snapshot when the stream gets long; load it before the next replay; the aggregate hydrates from the snapshot and the few events that follow. @frenchexdev/ddd-snapshot-store reifies that responsibility, ships the port plus memory and Postgres adapters, and — like the event-store family — leaves the runtime as parallel stubs whose contract surface is firm.
What Snapshot Store Reifies
The snapshot store's job is narrower than the event store's. Two operations dominate. save(aggregateId, snapshot, version) persists the snapshot keyed by aggregate id, recording at which event-stream version it was taken — and replaces any earlier snapshot for that aggregate, because only the latest one matters; older snapshots are superseded by the events that followed them. load(aggregateId) returns the most recent snapshot or null if none exists; the aggregate's replay loop checks the snapshot first, then loads events from the event store starting at the snapshot's version plus one.
The "replace, do not append" semantics distinguish the snapshot store from the event store. Events are history — never overwritten. Snapshots are compaction — older ones are useless once a newer one is recorded. The pattern's persistence contract is a latest-only per-aggregate store, not a log.
The Runtime: ddd-snapshot-store and adapters
Three packages — ddd-snapshot-store, ddd-snapshot-store-memory, ddd-snapshot-store-postgres — all ship the same STUB_KIND/describeStub() surface pinned to SnapshottingCompactionRequirement. Same scaffolding discipline as the event-store family: the namespace is occupied, the canonical feature is visible on the compliance map, the runtime arrives when a consumer drives the actual I/O.
The Contract Surface
// Sketch — the actual port shape when the runtime de-stubs.
@Port({
name: 'SnapshotStore',
direction: 'outbound',
conformanceSuite: 'SnapshotStoreConformance',
})
export abstract class SnapshotStorePort {
abstract save(
aggregateId: AggregateId,
snapshot: SnapshotPayload,
version: number, // the event-stream version this snapshot captures
): Promise<void>;
abstract load(
aggregateId: AggregateId,
): Promise<{ snapshot: SnapshotPayload; version: number } | null>;
}// Sketch — the actual port shape when the runtime de-stubs.
@Port({
name: 'SnapshotStore',
direction: 'outbound',
conformanceSuite: 'SnapshotStoreConformance',
})
export abstract class SnapshotStorePort {
abstract save(
aggregateId: AggregateId,
snapshot: SnapshotPayload,
version: number, // the event-stream version this snapshot captures
): Promise<void>;
abstract load(
aggregateId: AggregateId,
): Promise<{ snapshot: SnapshotPayload; version: number } | null>;
}Two methods, one optional return shape. save is fire-and-forget — write the snapshot and the version atomically. load returns either the most recent snapshot with its version (so the caller knows where to start the event replay) or null (so the caller knows to replay from event zero).
The conformance suite — declared as 'SnapshotStoreConformance' — enforces the "replace, do not append" semantics: a test that calls save(id, snapA, 100) then save(id, snapB, 200) then load(id) must see snapB. Adapters that quietly accumulate snapshots fail the suite even if their individual operations work.
The two adapters fit the two operational regimes. The memory adapter is Map<AggregateId, { snapshot, version }> — fast, lossy on restart, ideal for tests. The Postgres adapter uses an UPSERT keyed by aggregate id, transactional with whatever else the aggregate is writing, durable across restarts. Like the event store, the port shape is the same; the substrate is the choice.
Cross-Links
- Persists
@Snapshotinstances — every snapshot type lives in theSNAPSHOT_REGISTRYthat codegen emits. - Paired with
@EventStore— the aggregate's replay loop loads the snapshot first, then loads events fromversion + 1. - Called by Event-Sourcing-shaped
@AggregateRootloads. - Follows the
@Port/@Adapterdiscipline — one port, multiple substrate-specific adapters, one conformance suite.
Back to the series index.