Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Append-Only Log, Adapters by Substrate

The event store is what makes event sourcing a system rather than a discussion. The aggregate emits events; the event store appends them durably; the next time the aggregate is loaded, the store hands back the full stream and the aggregate replays. Without the store, there is no source of truth. The @frenchexdev/ddd-event-store family — port plus three substrate adapters — ships as M4/M5 stubs whose contract surface is the part the rest of the corpus is designed around.


What Event Store Reifies

A traditional repository stores the latest state of an aggregate. An event store stores every event that ever happened to an aggregate, in append-only order, keyed by stream. A stream is the per-aggregate ordered log: Subscription:sub_abc has all the events that ever applied to that subscription, in the order they happened. Streams never shorten — events are never deleted, only appended.

Two operations dominate the contract. append(stream, events, expectedVersion) writes one or more events to the end of a stream and refuses the write if the stream's current head is not at the expected version — this is the optimistic-concurrency check that prevents two concurrent commands on the same aggregate from both succeeding with conflicting events. load(stream, fromVersion?) reads the stream, returning events in order, optionally from a specific position (which is what makes snapshot-driven short-circuit replay possible — load from the snapshot's lastVersion, not from zero).

The interesting design choice in this corpus is that the port is one package, the adapters are separate packages, and every adapter is judged against a single conformance suite. Same port, three substrate implementations: an in-memory adapter for tests (no I/O, instant), a Postgres adapter for production (transactional with the outbox), a Kafka adapter for high-throughput cross-process delivery. The choice of substrate is operational; the contract is structural.


The Runtime: ddd-event-store and adapters

The four packages — ddd-event-store, ddd-event-store-memory, ddd-event-store-postgres, ddd-event-store-kafka — all ship the same minimal surface at this milestone: STUB_KIND, describeStub(), and a StubMetadata literal pinning them to the canonical feature EventSourcingCanonicalFeature and the canonical requirement REQ-EVENT-SOURCING-PERSISTED-TRUTH.

// ddd-event-store/src/stub.ts (and the three adapter packages — identical shape)
export const STUB_KIND = 'stub' as const;
export interface StubMetadata {
  readonly kind: typeof STUB_KIND;
  readonly status: 'stub';
  readonly canonicalFeature: 'EventSourcingCanonicalFeature';
  readonly canonicalRequirements: readonly ['REQ-EVENT-SOURCING-PERSISTED-TRUTH'];
}

Shipping the four packages as parallel stubs is a deliberate scaffolding move. The compliance system can see the canonical feature exists across four implementations; the workspace can already depend on the package names; the conformance suite can be authored against the eventual port shape before any adapter ships its real I/O. The four packages occupy the namespace they will eventually fill.


The Contract Surface

The eventual port shape, sketched from the conformance discipline EventSourcingCanonicalFeature will impose:

// Sketch — the actual port abstract class lands when the runtime de-stubs.

@Port({
  name: 'EventStore',
  direction: 'outbound',
  conformanceSuite: 'EventStoreConformance',
})
export abstract class EventStorePort {
  abstract append(
    stream:          StreamId,
    events:          readonly DomainEventPayload[],
    expectedVersion: number,        // optimistic concurrency check
  ): Promise<Result<AppendOutcome, ConcurrencyError>>;

  abstract load(
    stream:      StreamId,
    fromVersion: number = 0,
  ): Promise<readonly StoredEvent[]>;

  abstract subscribeAll(
    fromCheckpoint?: Checkpoint,
  ): AsyncIterable<StoredEvent>;     // for projections + outbox dispatchers
}

Three methods, three operational regimes. append is the write path — accepts a stream, the events to write, and the version the caller believes the stream is at; returns a Result because optimistic-concurrency conflict is a domain outcome the caller must handle. load is the per-aggregate read path — feeds the replay loop in Part 12. subscribeAll is the cross-stream read path that projections and outbox dispatchers consume — every event in the system, in commit order, with checkpoints so consumers can resume.

The three adapters will satisfy the same shape with very different mechanics. The memory adapter holds streams in a Map<StreamId, StoredEvent[]>; append pushes onto the array, load slices it. Useful for tests because every operation is synchronous-fast and the conformance suite can run thousands of cases in seconds. The Postgres adapter holds streams in a single events table with (stream_id, version) as a composite primary key; append runs INSERT with the version check as a WHERE NOT EXISTS guard, transactional with whatever else the calling aggregate is writing — this is what makes the outbox pattern align with the event store. The Kafka adapter holds streams as keyed messages on a topic partitioned by stream_id; append produces messages, load consumes from the beginning of a partition with a stream-id filter; high throughput, weaker ordering guarantees that the conformance suite has to spell out explicitly.

The conformance suite — declared in the port's conformanceSuite: 'EventStoreConformance' field — is the single test class every adapter must pass. The corpus's discipline is: the port defines the contract, the conformance suite enforces it, the adapter chooses the substrate. The day a new adapter ships (a Redis Streams adapter? An S3-backed log?) the work is: implement the port, run the conformance suite, see green, ship.


Why Three Adapters

The three adapters are not arbitrary. Each one fits an operational regime that the others cannot.

The memory adapter is for the test suite. Conformance tests, integration tests, BDD scenarios — all run faster with no I/O, no network, no transactional overhead. The contract is the same; the speed is what is different. Without an in-memory adapter, every event-sourced test would pay the cost of a Postgres round-trip per event, and a 200-event aggregate test takes minutes instead of milliseconds.

The Postgres adapter is for production systems where the event store needs to be transactional with adjacent writes — most importantly with the outbox. The outbox dispatcher needs to see exactly the events the aggregate's transaction committed, no more, no less. Same database, same transaction, atomic — the Postgres adapter is what makes that property real.

The Kafka adapter is for cross-process, cross-service event delivery at scale. Postgres is excellent up to perhaps tens of thousands of events per second per aggregate stream; beyond that, the writer-per-stream concurrency limit becomes the bottleneck. Kafka's partition model gives horizontal scaling — partition by stream_id, and concurrent appends to different aggregates are independent producer calls. The trade-off is that Kafka's ordering guarantees are per-partition, not global, and the conformance suite has to be explicit about which ordering the corpus depends on.

A workspace that needs all three uses the in-memory adapter in *.test.ts, the Postgres adapter in development and small-scale production, the Kafka adapter in high-throughput services. Same port import, three deployment targets.


The event store is the persistence end of event sourcing and connects to several patterns.

  • It persists @DomainEvent payloads emitted by @AggregateRoot instances under the @EventSourcing discipline.
  • The @SnapshotStore sits next to it: long streams short-circuit through snapshots, the snapshot stores the rebuilt state at version N, the event store loads only from N+1.
  • A @Projection reads the same stream that the aggregate writes — subscribeAll is the read path projections consume.
  • The conformance suite declared on the port is the same shape as the port section in Part 07 describes.

Back to the series index.

⬇ Download