Compacted State, Replay Short-Circuited
An event-sourced aggregate with twenty events replays fast. An aggregate with twenty thousand events replays slowly enough that the first cold load of the day shows up in latency monitoring. Snapshots fix that by recording the aggregate's state at a known version so replay can start from there instead of from event zero. @frenchexdev/ddd-snapshot reifies the snapshot primitive — what makes long-lived event-sourced systems remain fast.
What Snapshot Reifies
A snapshot is an immutable cut of state at a known version. Take aggregate Subscription:sub_abc at event 1,200; the snapshot records "at version 1,200, the subscription state was {status: 'active', plan: 'pro', renewals: 8, ...}". The next time the aggregate loads, the store hands back the snapshot plus events 1,201 onwards. The replay loop applies the few new events, the state is current, the cold-load latency drops from "walk 20,000 events" to "load one snapshot, walk three events."
The pattern's discipline mirrors event sourcing's. Snapshots are immutable — once persisted, the fields are read-only, and the snapshot must serialise reliably. Snapshots are domain truth, not infrastructure shape — the analyzer refuses pg/mysql/etc imports because a snapshot that depends on its persistence target is no longer compactable across substrates. Snapshots use stable types — no mutable Date, prefer ISO-strings, the same logic as event sourcing.
The frequency of snapshotting is a tuning parameter, not a part of the pattern. Some teams snapshot every 100 events, some every 1,000. A snapshot too frequent wastes storage; too rare and the cold-load latency creeps back. The pattern reifies the shape; the cadence is operational.
The Runtime: ddd-snapshot
The runtime is an M4/M5 stub pinned to SnapshottingCompactionRequirement. Like ddd-outbox and ddd-event-sourcing, the runtime ships STUB_KIND/describeStub() while the analyzer and codegen carry the contract forward. The decorator will arrive when a consumer drives it; until then, the codegen-emitted stub is the substrate teams fill in by hand.
The Analyzer: ddd-snapshot-analyzer
Spec-first (spec.ts). Pattern SNAPSHOT under requirement SnapshottingCompactionRequirement, priority Medium. Three rules that read as the snapshot-specific specialisation of the event-sourcing trio:
rules: [
{ kind: 'require-name-suffix', code: 'DDD-SNAPSHOT-001', severity: 'error',
suffix: 'Snapshot',
message: 'Snapshot type must end with "Snapshot" suffix (snapshots are keyed by type for replay)' },
{ kind: 'forbid-framework-types', code: 'DDD-SNAPSHOT-002', severity: 'warning',
forbiddenTypeNames: ['Date'],
message: 'Snapshot field parameter "{type}" uses a mutable Date — prefer an ISO-string for compaction-safe persistence' },
{ kind: 'forbid-infrastructure-import', code: 'DDD-SNAPSHOT-003', severity: 'error',
forbiddenModulePatterns: ['pg', 'mysql', 'mongodb', 'redis', 'kafka'],
message: 'Snapshot module imports infrastructure module "{module}" — snapshots must remain domain-only' },
],rules: [
{ kind: 'require-name-suffix', code: 'DDD-SNAPSHOT-001', severity: 'error',
suffix: 'Snapshot',
message: 'Snapshot type must end with "Snapshot" suffix (snapshots are keyed by type for replay)' },
{ kind: 'forbid-framework-types', code: 'DDD-SNAPSHOT-002', severity: 'warning',
forbiddenTypeNames: ['Date'],
message: 'Snapshot field parameter "{type}" uses a mutable Date — prefer an ISO-string for compaction-safe persistence' },
{ kind: 'forbid-infrastructure-import', code: 'DDD-SNAPSHOT-003', severity: 'error',
forbiddenModulePatterns: ['pg', 'mysql', 'mongodb', 'redis', 'kafka'],
message: 'Snapshot module imports infrastructure module "{module}" — snapshots must remain domain-only' },
],Same shape, same severities, same forbidden type and module lists as event sourcing. The repetition is deliberate — both patterns share the persisted truth discipline and the analyzer says so.
The Codegen: ddd-snapshot-codegen
Spec-first (spec.ts). Two templates that parallel the event-sourcing codegen.
templates/snapshot-stub.ts emits an immutable class <SnapshotClassName>Stub with readonly constructor parameters sorted alphabetically and a kind literal discriminator. The discriminator drives the per-snapshot deserialisation path on load.
// AUTO-GENERATED by ddd-snapshot-codegen:snapshot-stub — do not edit.
/**
* Snapshot: SubscriptionSnapshot (aggregate=Subscription).
* Compacts the event stream — instances are immutable.
*/
export class SubscriptionSnapshotStub {
readonly kind = 'SubscriptionSnapshot' as const;
// state: plan, renewals, status, subscriptionId
constructor(
public readonly plan: BillingPlanId,
public readonly renewals: number,
public readonly status: 'active' | 'suspended' | 'cancelled',
public readonly subscriptionId: SubscriptionId,
) {}
}// AUTO-GENERATED by ddd-snapshot-codegen:snapshot-stub — do not edit.
/**
* Snapshot: SubscriptionSnapshot (aggregate=Subscription).
* Compacts the event stream — instances are immutable.
*/
export class SubscriptionSnapshotStub {
readonly kind = 'SubscriptionSnapshot' as const;
// state: plan, renewals, status, subscriptionId
constructor(
public readonly plan: BillingPlanId,
public readonly renewals: number,
public readonly status: 'active' | 'suspended' | 'cancelled',
public readonly subscriptionId: SubscriptionId,
) {}
}templates/snapshot-registry.ts emits the workspace-wide registry, sorted by aggregate/class for deterministic output. The snapshot store reads this registry to know which classes to deserialise to which aggregates.
// AUTO-GENERATED by ddd-snapshot-codegen:snapshot-registry — do not edit.
import { InvoiceSnapshot } from '../../snapshots/invoice.snapshot.js';
import { SubscriptionSnapshot } from '../../snapshots/subscription.snapshot.js';
export const SNAPSHOT_REGISTRY = [
{ aggregate: 'Invoice', type: 'InvoiceSnapshot', ctor: InvoiceSnapshot },
{ aggregate: 'Subscription', type: 'SubscriptionSnapshot', ctor: SubscriptionSnapshot },
] as const;
export const SNAPSHOT_NAMES = ['InvoiceSnapshot', 'SubscriptionSnapshot'] as const;// AUTO-GENERATED by ddd-snapshot-codegen:snapshot-registry — do not edit.
import { InvoiceSnapshot } from '../../snapshots/invoice.snapshot.js';
import { SubscriptionSnapshot } from '../../snapshots/subscription.snapshot.js';
export const SNAPSHOT_REGISTRY = [
{ aggregate: 'Invoice', type: 'InvoiceSnapshot', ctor: InvoiceSnapshot },
{ aggregate: 'Subscription', type: 'SubscriptionSnapshot', ctor: SubscriptionSnapshot },
] as const;
export const SNAPSHOT_NAMES = ['InvoiceSnapshot', 'SubscriptionSnapshot'] as const;The deliberate parallelism with event-sourcing — same templates, same rules shape — is what makes the two patterns compose. A team that ships an event-sourced aggregate can ship the matching snapshot type in an afternoon: same registry mechanics, same immutability discipline, same persistence contract via the snapshot store.
Cross-Links
- Built on
@DomainEventand Event Sourcing — snapshots only make sense when state is derived from an event stream. - Persisted via
@SnapshotStore— same port-and-adapter discipline as the event store. - Read by the
@AggregateRootreplay loop before the event stream is applied. - Aligns with the
@EventStore'sload(stream, fromVersion)— the snapshot's version becomes thefromVersionargument.
Back to the series index.