The State Is the Event Stream
Event sourcing is the architectural inversion most people meet through Greg Young's talks and then misremember as "store all the events". The pattern is more precise: the events are the source of truth, the state is a function over them. Rebuild the state from the events on demand. Append, never overwrite. The aggregate's current state is a fold over its stream. @frenchexdev/ddd-event-sourcing reifies that discipline in a triplet where the runtime is still a stub but the analyzer and codegen contracts are firm enough to scaffold a working system around.
What Event Sourcing Reifies
A classical aggregate stores its state and emits domain events as a notification side-effect. Save the aggregate, fire the events, the state and the events agree because they were produced together. An event-sourced aggregate stores its events and derives its state by replay. Save the events, the state is whatever applying those events in order produces — same shape, opposite direction of dependency. The flip looks small. It is enormous.
What it buys you, in order of obviousness: full history (every past state is reconstructible), perfect audit (you cannot lose the trail of what happened), temporal querying (replay the stream up to any past moment), and rebuildable projections (a new read-model is just replay the stream and apply the projection function). What it costs, in the same order: schema evolution becomes a serious discipline (you cannot rewrite the past), every state mutation must be expressible as a single event (no more "and also bump this counter"), aggregates become noticeably more code (separate apply(event) methods, no setters), and the team has to develop intuitions about what is a fact versus what is derived.
The discipline that makes event sourcing work — and the part this triplet's analyzer enforces — is that events are domain truth. They do not import infrastructure. They do not carry mutable timestamps (a Date is mutable in JavaScript; an ISO string is not). Their names end with Event because the persisted-truth registry is keyed by type name. The analyzer makes those non-negotiable so a sloppy event drift cannot quietly corrupt the source of truth.
The Runtime: ddd-event-sourcing
The runtime is, like ddd-outbox, a deliberate M4/M5 stub. stub.ts pins the canonical feature (EventSourcingCanonicalFeature) and the canonical requirement (REQ-EVENT-SOURCING-PERSISTED-TRUTH). The intent is to ship the contract surface — what an event-sourced aggregate must look like, what an event must satisfy — before committing to a specific replay machinery.
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'];
}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'];
}When the runtime does land, the shape the codegen and the analyzer are scaffolding suggests two things. First, a per-event class with a kind discriminator and readonly payload fields — the event is its own type, not a tagged union of one big shape. Second, an EVENT_REGISTRY indexed by aggregate name and event class — replay starts by picking the aggregate, walking its stream, and dispatching each event by kind to an apply(event) method on the rebuilt aggregate.
A sketch of what an event-sourced Subscription aggregate will look like (the decorator does not yet exist; the codegen-emitted event stub is concrete):
// Sketch — the decorator @EventSourced is not yet shipped; the event class shape is.
@EventSourced({ aggregate: 'Subscription' })
export class Subscription extends AggregateRoot {
private status: 'pending' | 'active' | 'cancelled' = 'pending';
// Application calls this — produces an event, applies it, appends it.
start(plan: BillingPlanId, at: string): void {
const e = new SubscriptionStartedEvent(this.id, plan, at);
this.apply(e);
this.record(e); // <-- queued for the event store
}
// Replay path: the framework calls this once per event from the stream.
protected applySubscriptionStarted(e: SubscriptionStartedEvent): void {
this.status = 'active';
}
}// Sketch — the decorator @EventSourced is not yet shipped; the event class shape is.
@EventSourced({ aggregate: 'Subscription' })
export class Subscription extends AggregateRoot {
private status: 'pending' | 'active' | 'cancelled' = 'pending';
// Application calls this — produces an event, applies it, appends it.
start(plan: BillingPlanId, at: string): void {
const e = new SubscriptionStartedEvent(this.id, plan, at);
this.apply(e);
this.record(e); // <-- queued for the event store
}
// Replay path: the framework calls this once per event from the stream.
protected applySubscriptionStarted(e: SubscriptionStartedEvent): void {
this.status = 'active';
}
}Two paths into apply: one driven by the application command (start), one driven by the replay loop. The same apply method is the only way the aggregate's state changes — there are no setters, no direct mutations. The runtime's job, when it ships, is to provide the AggregateRoot.apply(event) plumbing and the replay loop; until then, the consumer wires it by hand, and the codegen-emitted event class is the substrate they wire against.
The Analyzer: ddd-event-sourcing-analyzer
The analyzer is spec-first (spec.ts). Pattern EVENTSOURCING under requirement EventSourcingPersistedTruthRequirement, priority: 'High', parent subpath secondary (event sourcing is a secondary discipline layered on the primary aggregate/event vocabulary).
Three rules, each enforcing a different aspect of the persisted-truth discipline.
DDD-EVENTSOURCING-001 requires the Event suffix at error severity — the registry is keyed by class name, the replay loop dispatches by class name, and a missing suffix breaks both. Different from the strategic patterns' info-severity suffix rules, this one is non-negotiable.
DDD-EVENTSOURCING-002 rejects Date parameters in event method signatures at warning severity. A Date is mutable in JavaScript — new Date() produces an object whose internal value can be changed after construction — which means a deserialised event might be mutated after replay. The rule recommends ISO-8601 strings instead. Strings are immutable, parsable, and reliably serialisable.
DDD-EVENTSOURCING-003 rejects infrastructure imports in event modules — pg, mysql, mongodb, redis, kafka. Events are domain truth; if an event imports redis, it is no longer a domain event, it is a cache-coupling that pretends to be one. The rule is at error severity, like the port equivalent (DDD-PORT-003), because the leak is structural rather than stylistic.
export const eventSourcingAnalyzerSpec = defineAnalyzerSpec({
patternId: 'EVENTSOURCING',
featureId: 'EVENT-SOURCING-ANALYZER',
priority: 'High',
parentRequirementSubpath: 'secondary',
// ...
rules: [
{ kind: 'require-name-suffix', code: 'DDD-EVENTSOURCING-001', severity: 'error', targetAC: 'name-suffix',
suffix: 'Event',
message: 'Event type must end with "Event" suffix (events are persisted truth keyed by type name)' },
{ kind: 'forbid-framework-types', code: 'DDD-EVENTSOURCING-002', severity: 'warning', targetAC: 'no-mutable-timestamp',
forbiddenTypeNames: ['Date'],
message: 'Event method parameter "{type}" uses a mutable Date — prefer an ISO-string for replayable persisted truth' },
{ kind: 'forbid-infrastructure-import', code: 'DDD-EVENTSOURCING-003', severity: 'error', targetAC: 'no-infrastructure-imports',
forbiddenModulePatterns: ['pg', 'mysql', 'mongodb', 'redis', 'kafka'],
message: 'Event module imports infrastructure module "{module}" — events must remain domain-only' },
],
});export const eventSourcingAnalyzerSpec = defineAnalyzerSpec({
patternId: 'EVENTSOURCING',
featureId: 'EVENT-SOURCING-ANALYZER',
priority: 'High',
parentRequirementSubpath: 'secondary',
// ...
rules: [
{ kind: 'require-name-suffix', code: 'DDD-EVENTSOURCING-001', severity: 'error', targetAC: 'name-suffix',
suffix: 'Event',
message: 'Event type must end with "Event" suffix (events are persisted truth keyed by type name)' },
{ kind: 'forbid-framework-types', code: 'DDD-EVENTSOURCING-002', severity: 'warning', targetAC: 'no-mutable-timestamp',
forbiddenTypeNames: ['Date'],
message: 'Event method parameter "{type}" uses a mutable Date — prefer an ISO-string for replayable persisted truth' },
{ kind: 'forbid-infrastructure-import', code: 'DDD-EVENTSOURCING-003', severity: 'error', targetAC: 'no-infrastructure-imports',
forbiddenModulePatterns: ['pg', 'mysql', 'mongodb', 'redis', 'kafka'],
message: 'Event module imports infrastructure module "{module}" — events must remain domain-only' },
],
});A failing example trips DDD-EVENTSOURCING-002: an event with a Date field looks innocent but is the start of replay subtle bugs.
@DomainEvent({ version: 1 })
export class SubscriptionStartedEvent {
constructor(
public readonly subscriptionId: SubscriptionId,
public readonly startedAt: Date, // <-- mutable
) {}
}
// DDD-EVENTSOURCING-002 [warning] Event method parameter "Date" uses a mutable Date —
// prefer an ISO-string for replayable persisted truth
// AC: EVENT-SOURCING-ANALYZER/no-mutable-timestamp@DomainEvent({ version: 1 })
export class SubscriptionStartedEvent {
constructor(
public readonly subscriptionId: SubscriptionId,
public readonly startedAt: Date, // <-- mutable
) {}
}
// DDD-EVENTSOURCING-002 [warning] Event method parameter "Date" uses a mutable Date —
// prefer an ISO-string for replayable persisted truth
// AC: EVENT-SOURCING-ANALYZER/no-mutable-timestampThe remedy is to take an ISO-8601 string in the event and parse it at the boundary if a Date is needed locally — the event stays serialisable, the aggregate code parses on read.
The Codegen: ddd-event-sourcing-codegen
The codegen is spec-first (spec.ts) and ships two templates. Both invariants — event-stub-is-immutable and registry-lists-all-events — encode the architectural promises that event-sourced systems cannot ship without.
templates/event-stub.ts emits a concrete event class with readonly constructor parameters (the only way to declare immutable fields in TypeScript without ceremony) and a kind discriminator field equal to the class name as an as const literal. The discriminator is the dispatch key — pattern-matching on kind is how the replay loop finds the right apply method.
// AUTO-GENERATED by ddd-event-sourcing-codegen:event-stub — do not edit.
/**
* Persisted event: SubscriptionStartedEvent (aggregate=Subscription).
* Events are the source of truth — instances must be immutable.
*/
export class SubscriptionStartedEventStub {
readonly kind = 'SubscriptionStartedEvent' as const;
// payload: planId, startedAt, subscriptionId
constructor(
public readonly planId: BillingPlanId,
public readonly startedAt: string,
public readonly subscriptionId: SubscriptionId,
) {}
}// AUTO-GENERATED by ddd-event-sourcing-codegen:event-stub — do not edit.
/**
* Persisted event: SubscriptionStartedEvent (aggregate=Subscription).
* Events are the source of truth — instances must be immutable.
*/
export class SubscriptionStartedEventStub {
readonly kind = 'SubscriptionStartedEvent' as const;
// payload: planId, startedAt, subscriptionId
constructor(
public readonly planId: BillingPlanId,
public readonly startedAt: string,
public readonly subscriptionId: SubscriptionId,
) {}
}Fields are sorted alphabetically for determinism. The kind: '<ClassName>' as const discriminator is the part downstream code relies on: a typed switch over event.kind narrows the event variable to its specific class type without runtime reflection.
templates/event-registry.ts emits the workspace-wide event registry, sorted by aggregateName/className so the diff is stable across builds. The replay loop reads this registry to know which events belong to which aggregate, which is what makes event sourcing compose with multiple aggregates in the same store.
// AUTO-GENERATED by ddd-event-sourcing-codegen:event-registry — do not edit.
import { InvoiceGeneratedEvent } from '../../events/invoice-generated.event.js';
import { SubscriptionCancelledEvent } from '../../events/subscription-cancelled.event.js';
import { SubscriptionStartedEvent } from '../../events/subscription-started.event.js';
export const EVENT_REGISTRY = [
{ aggregate: 'Invoice', type: 'InvoiceGeneratedEvent', ctor: InvoiceGeneratedEvent },
{ aggregate: 'Subscription', type: 'SubscriptionCancelledEvent', ctor: SubscriptionCancelledEvent },
{ aggregate: 'Subscription', type: 'SubscriptionStartedEvent', ctor: SubscriptionStartedEvent },
] as const;
export const EVENT_NAMES = ['InvoiceGeneratedEvent', 'SubscriptionCancelledEvent', 'SubscriptionStartedEvent'] as const;// AUTO-GENERATED by ddd-event-sourcing-codegen:event-registry — do not edit.
import { InvoiceGeneratedEvent } from '../../events/invoice-generated.event.js';
import { SubscriptionCancelledEvent } from '../../events/subscription-cancelled.event.js';
import { SubscriptionStartedEvent } from '../../events/subscription-started.event.js';
export const EVENT_REGISTRY = [
{ aggregate: 'Invoice', type: 'InvoiceGeneratedEvent', ctor: InvoiceGeneratedEvent },
{ aggregate: 'Subscription', type: 'SubscriptionCancelledEvent', ctor: SubscriptionCancelledEvent },
{ aggregate: 'Subscription', type: 'SubscriptionStartedEvent', ctor: SubscriptionStartedEvent },
] as const;
export const EVENT_NAMES = ['InvoiceGeneratedEvent', 'SubscriptionCancelledEvent', 'SubscriptionStartedEvent'] as const;The two templates plus the three analyzer rules form the contract surface that an event-sourced application must satisfy. The runtime stub will, when shipped, drop into the slot the contract leaves open — and because the contract is already firm, the consumers that build on top will not need to rewrite when the runtime lands.
Cross-Links
Event sourcing is the layered discipline on top of several primary patterns.
- It builds on
@DomainEvent: every event-sourced event is itself a domain event. The version field becomes load-bearing — schema migrations live in the gap between versions. - The events persist in an
@EventStore. The store appends, the aggregate replays, the store guarantees ordering within an aggregate stream. - For aggregates with long histories, periodic
@Snapshotcuts let replay short-circuit instead of walking the full stream from event zero. - A
@Projectionis a fold over the same events, producing a read-optimised view that is independent of the aggregate's own state shape. - The
@AggregateRootitself changes shape — direct setters becomeapply(event)methods, and the aggregate becomes responsible for producing events that, replayed, reproduce its state.
Back to the series index.