Load, Save, Find by Correlation
A saga that does not survive a process restart is no saga at all — it is a long method call that happens to time out. @frenchexdev/ddd-saga-store reifies the persistence boundary that makes sagas long-running: load the saga's state from storage when its next event arrives, save after each step, delete when the saga completes, and — crucially — find the right saga by the correlation key extracted from incoming events. Four operations, one shipped in-memory adapter, two stubs awaiting consumers, one shipped conformance suite.
What Saga Store Reifies
The saga store is operationally distinct from the event store in two ways. First, it is latest-only per saga — like the snapshot store — because the framework only ever needs the saga's current state, not its history (the saga's events live in the event store, but the saga's state is what the store holds). Second, it supports finding by correlation, which the other stores do not. The first event that triggers a new saga has no saga id — only the correlation key from the event (orderId, subscriptionId). The store must look up is there a saga instance keyed by correlationKey === 'order-42'? and either return the in-flight snapshot or hand back undefined so the framework can create a fresh one.
Four operations cover the responsibility. load(sagaId) returns the snapshot or undefined. save(snapshot) upserts the snapshot. delete(sagaId) removes a completed saga so the table does not grow without bound. findByCorrelation(correlationKey) is the routing primitive: given the correlation key extracted from an event, return the matching in-flight snapshot or undefined. The fourth operation is what makes the pattern work — without it, the framework cannot route the first event to the right saga.
The Port and Conformance Suite: ddd-saga-store
port.ts declares the real surface. The package ships a SagaSnapshot record (sagaId, correlationKey, state, version) and a SagaStorePort interface with four async methods:
export interface SagaSnapshot {
readonly sagaId: string;
readonly correlationKey: string;
readonly state: Readonly<Record<string, unknown>>;
readonly version: number;
}
export interface SagaStorePort {
load(sagaId: string): Promise<SagaSnapshot | undefined>;
save(snapshot: SagaSnapshot): Promise<void>;
delete(sagaId: string): Promise<void>;
findByCorrelation(correlationKey: string): Promise<SagaSnapshot | undefined>;
}export interface SagaSnapshot {
readonly sagaId: string;
readonly correlationKey: string;
readonly state: Readonly<Record<string, unknown>>;
readonly version: number;
}
export interface SagaStorePort {
load(sagaId: string): Promise<SagaSnapshot | undefined>;
save(snapshot: SagaSnapshot): Promise<void>;
delete(sagaId: string): Promise<void>;
findByCorrelation(correlationKey: string): Promise<SagaSnapshot | undefined>;
}The version field plays the same role as AggregateRootMetadata.version — optimistic concurrency control under concurrent writes for the same saga. The field naming is deliberately aligned with aggregate-root.
conformance.ts exports runSagaStoreConformanceSuite(adapter), a real test harness every adapter author runs to verify port compliance. The suite returns plain pass/fail results so it can be embedded in any test runner; consumers wrap it in @FeatureTest + @Verifies. The four invariants it enforces: save followed by load must round-trip; findByCorrelation must return the saved snapshot when the key matches; delete must be idempotent and make subsequent load return undefined; load of an unknown id must return undefined.
The Memory Adapter: ddd-saga-store-memory
adapter.ts ships a real InMemorySagaStore class — Map<sagaId, SagaSnapshot>-backed, suitable for tests and single-process dev:
export class InMemorySagaStore implements SagaStorePort {
private readonly store = new Map<string, SagaSnapshot>();
async load(sagaId: string): Promise<SagaSnapshot | undefined> {
return this.store.get(sagaId);
}
async save(snapshot: SagaSnapshot): Promise<void> {
this.store.set(snapshot.sagaId, snapshot);
}
async delete(sagaId: string): Promise<void> {
this.store.delete(sagaId);
}
async findByCorrelation(correlationKey: string): Promise<SagaSnapshot | undefined> {
return Array.from(this.store.values()).find(s => s.correlationKey === correlationKey);
}
}export class InMemorySagaStore implements SagaStorePort {
private readonly store = new Map<string, SagaSnapshot>();
async load(sagaId: string): Promise<SagaSnapshot | undefined> {
return this.store.get(sagaId);
}
async save(snapshot: SagaSnapshot): Promise<void> {
this.store.set(snapshot.sagaId, snapshot);
}
async delete(sagaId: string): Promise<void> {
this.store.delete(sagaId);
}
async findByCorrelation(correlationKey: string): Promise<SagaSnapshot | undefined> {
return Array.from(this.store.values()).find(s => s.correlationKey === correlationKey);
}
}The memory adapter is the conformance suite's primary test surface — running runSagaStoreConformanceSuite(new InMemorySagaStore()) is how the suite itself is dog-fooded. Lossy on restart, instant otherwise. Not safe across processes — use a real adapter for production.
The Postgres and Redis Adapters: stubs
ddd-saga-store-postgres and ddd-saga-store-redis currently ship as STUB_KIND/describeStub() stubs pinned to SagaLongRunningOrchestrationRequirement. The namespace is claimed, the canonical feature is visible, the conformance suite is ready to drive each adapter — only the I/O implementation is deferred until a consumer drives the choice.
The two operational regimes the stubs reserve. The Postgres adapter will hold snapshots in a table with a JSON state column and a generated column for the correlation key; lookups by sagaId are primary-key fast, lookups by correlationKey are indexed; transactional with the rest of the database. The Redis adapter will hold snapshots as hashes keyed by sagaId, plus a secondary key per correlation value — sub-millisecond latency for services that already use Redis, but the at-most-once semantics of Redis without persistence make it suitable only for short-lived sagas. The first production saga in the CV site (none currently) drives whether the postgres or redis variant de-stubs first.
Cross-Links
- Persists
@Sagainstances — the saga decorator names the store bypersistedAs. - Follows the
@Port/@Adapterdiscipline — the conformance suite is the test every adapter must pass. - Receives
@DomainEventtraffic indirectly: the orchestrator reads the correlation field from each event and callsfindByCorrelationto route. - Sits next to
@EventStoreand@SnapshotStore— three port families implementing the persistence half of event-driven DDD.
Back to the series index.