Read Models Folded from Events
In a CQRS system, write goes to the aggregate, read goes elsewhere — to a denormalised view shaped for the query. A projection is the function that builds that view by folding the event stream. @frenchexdev/ddd-projection reifies the fold so the read side is regenerable: throw away the read model at any time, replay the events, and the same projection function rebuilds it.
What Projection Reifies
The core insight is that read models are derived, not authoritative. The events in the event store are the truth; the read model is whatever a particular query needs to see, materialised for fast access. Different queries want different shapes — a per-customer dashboard wants a flat row of "open invoices + last payment + subscription tier", an admin search wants a flat-text-indexed table of every customer ever, a finance report wants a monthly aggregate. Each is a projection over the same underlying event stream.
The discipline projections require is harsh on first contact but freeing once internalised. Handlers must be idempotent — applying SubscriptionStarted twice must produce the same final read-model state as applying it once. The reason is replay: if the projection's storage is lost or the read model needs to be rebuilt with a new shape, the framework replays the entire event stream from event zero through every handler, and any non-idempotent handler would corrupt the rebuild.
Projections are append-friendly. A new field on the read model? Add an apply<Event> handler that sets it, replay from zero, the field populates. A new projection entirely? Declare it, replay, the read model materialises. The cost of a new query is small because the source of truth is preserved by the event store, not by the read shape.
The Runtime: ddd-projection
M4/M5 stub pinned to ProjectionFromEventsRequirement. The runtime decorator will arrive when a consumer drives the framework; the analyzer and codegen meanwhile pin the shape.
The Analyzer: ddd-projection-analyzer
Spec-first (spec.ts). Pattern PROJECTION under requirement ProjectionFromEventsRequirement, priority Medium. Two rules — DDD-PROJECTION-001 recommends the Projection suffix at info severity; DDD-PROJECTION-002 keeps one projection per file at error severity.
The modest rule list reflects that idempotence — the most important projection invariant — is hard to enforce statically. A future cross-AST rule could verify that handlers do not mutate global state, do not depend on the current wall clock, do not call non-idempotent infrastructure (random number generators, time-based ids). For now, the convention is documented and the codegen's emitted prose reminds the developer.
rules: [
{ kind: 'require-name-suffix', code: 'DDD-PROJECTION-001', severity: 'info', targetAC: 'name-suffix',
suffix: 'Projection',
message: 'Projection type should end with "Projection" suffix by convention' },
{ kind: 'single-per-file', code: 'DDD-PROJECTION-002', severity: 'error', targetAC: 'single-projection-per-file',
decoratorName: 'Projection',
message: 'File declares {count} @Projection classes — split one per file' },
],rules: [
{ kind: 'require-name-suffix', code: 'DDD-PROJECTION-001', severity: 'info', targetAC: 'name-suffix',
suffix: 'Projection',
message: 'Projection type should end with "Projection" suffix by convention' },
{ kind: 'single-per-file', code: 'DDD-PROJECTION-002', severity: 'error', targetAC: 'single-projection-per-file',
decoratorName: 'Projection',
message: 'File declares {count} @Projection classes — split one per file' },
],The Codegen: ddd-projection-codegen
Spec-first (spec.ts). Two templates. The interesting AC is projection-stub-emits-handler-per-event — the codegen must emit one apply<EventName> handler per declared event, not a single catch-all apply(event: any). Typed handlers are what make the projection compile-error if a handled event is removed from the system.
templates/projection-stub.ts consumes a ProjectionDescriptor (className, readModelName, eventNames: string[]) and emits a class with the read-model name as an as const literal field plus one async handler per event sorted alphabetically. Each handler throws NotImplemented until hand-completed; the docstring reminds the developer that handlers must be idempotent.
// AUTO-GENERATED by ddd-projection-codegen:projection-stub — do not edit.
/**
* Projection stub: materialises ReadModel "CustomerDashboardReadModel".
* Implement each `apply<Event>` handler to fold the event into the read model.
* Handlers must be idempotent so replay-from-zero converges.
*/
export class CustomerDashboardProjectionStub {
readonly readModelName = 'CustomerDashboardReadModel' as const;
async applyPaymentCapturedEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applyPaymentCapturedEvent');
}
async applySubscriptionCancelledEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applySubscriptionCancelledEvent');
}
async applySubscriptionStartedEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applySubscriptionStartedEvent');
}
}// AUTO-GENERATED by ddd-projection-codegen:projection-stub — do not edit.
/**
* Projection stub: materialises ReadModel "CustomerDashboardReadModel".
* Implement each `apply<Event>` handler to fold the event into the read model.
* Handlers must be idempotent so replay-from-zero converges.
*/
export class CustomerDashboardProjectionStub {
readonly readModelName = 'CustomerDashboardReadModel' as const;
async applyPaymentCapturedEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applyPaymentCapturedEvent');
}
async applySubscriptionCancelledEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applySubscriptionCancelledEvent');
}
async applySubscriptionStartedEvent(event: unknown): Promise<void> {
throw new Error('NotImplemented: CustomerDashboardProjectionStub.applySubscriptionStartedEvent');
}
}The registry template emits the workspace-wide list of projections with their read-model bindings, so the framework knows which projection feeds which read model and which events each projection consumes. A CI gate can verify that no read model is orphaned (declared but no projection feeds it) and that no projection points at a missing read model.
Cross-Links
- Folds streams of
@DomainEventinto denormalised views. - Reads its events from the
@EventStore—subscribeAll(fromCheckpoint)is the read path projections consume. - Materialises
@ReadModelinstances — the projection's output target. - Sits parallel to
@Saga: both consume events, but a projection updates a read model while a saga orchestrates side effects.
Back to the series index.