Skip to content

Level 4, Scale & operations

Levels 1–3 stay inside a single aggregate. Level 4 is where read models span many aggregates, and where you operate the whole thing in production.

A multi-stream projection (MSP) folds events from many aggregates into one table, here, revenue per customer across all invoices:

[approved.name]: async (event, tx) => {
const p = typedPayload(event, approved);
await updateMany(
tx,
invoiceDetailTable,
{ status: "approved", amountCents: p.amountCents },
{ invoiceId: event.aggregateId },
);
},
[paid.name]: async (event, tx) => {
await updateMany(
tx,
invoiceDetailTable,
{ status: "paid" },
{ invoiceId: event.aggregateId },
);
},
},
});
// Multi-stream projection: one row per customer. Fires ASYNC via the
// event-dispatcher (runOnce in tests, NOTIFY/LISTEN in production).
r.multiStreamProjection({
name: "customer-revenue",
table: customerRevenueTable,
apply: {
[paid.name]: async (event, tx) => {
// Resolve the customer: pull it from the invoice-detail projection
// that the inline projection just populated. Cross-projection reads
// are fine inside apply() — we're at the read model layer.

📄 On GitHub: samples/recipes/event-sourcing/src/feature.ts

Two things distinguish an MSP from the inline projection of Level 2:

  • It runs async via the event dispatcher (Postgres NOTIFY/LISTEN in production, runOnce() in tests), the write transaction doesn’t wait for it. Expect eventual consistency: the read model trails the write by milliseconds under normal load.
  • Its apply functions must be idempotent, delivery is at-least-once, so an apply may run twice for the same event. Upserts and incrementCounter-style conflict handling are the pattern; blind INSERTs are not.
Write transaction commits Event dispatcher Postgres NOTIFY/LISTEN in production Async apply() at-least-once — must be idempotent MSP table updated eventually consistent

The full wiring, consumer cursors, poison-event handling, lag metrics, is covered in the dedicated multi-stream projection guide.

Reading an MSP table is one line:

const next: InvoiceStateRecord = { ...state };
reduceInvoice(next as unknown as InvoiceState, evt);
return next;
},
{ ...initialInvoiceState } as InvoiceStateRecord,
// Auto-snapshot policy: once 100+ delta events pile up past the last
// snapshot, the read path persists a fresh one (best-effort). Bump
// snapshotVersion whenever the reducer's state SHAPE changes — a
// stale-shape snapshot is then ignored and rebuilt from the log.

Any projection can be dropped and re-derived from the log:

Terminal window
bun kumiko project rebuild

The rebuild replays into a shadow table and swaps under a fence, so live reads never see a half-built state.

bun kumiko project rebuild Replay every event into a shadow table Swap under a fence live reads never see a half-built state Old table dropped

This is the payoff of the whole model: changing a read model’s shape is a rebuild, not a data migration. Since framework#973 this includes sensitive/PII columns, event payloads carry the table ciphertext, so a rebuild reproduces every column byte-identically. The only intended divergence is crypto-shredding: after a subject is forgotten, its values are unreadable everywhere at once.

Before you rely on it, know the one rule that keeps rebuilds safe: never write an entity table directly. A row without an event cannot be replayed and would be silently dropped, CI guards and a runtime ghost-row check enforce this, and truly non-event-sourced tables (caches, cursors) belong in r.unmanagedTable instead.

Closed aggregates (a cancelled subscription, a completed order from 2022) can be archived: the stream stops replaying and its projections stop carrying the row, while the events stay in the log:

await ctx.appendEvent({
aggregateId: event.payload.id,
aggregateType: "showcase-invoice",
type: paid.name,
payload: { amountCents: event.payload.amountCents },
});
return { isSuccess: true as const, data: { id: event.payload.id } };
},
{

Archived streams read as empty (loadAggregate returns []), ops tooling passes { includeArchived: true } when it needs to look inside.

  • Consumers: an MSP consumer that hits a poison event stops after max attempts. kumiko consumer restart | skip | disable are the levers; monitor lag via getAllProjectionProgress.
  • Retention: event retention is destructive and interacts with consumer lag, a consumer that hasn’t caught up pins retention.
  • Idempotent side effects: async handlers may re-run. Anything that sends email or calls external APIs needs its own dedup.
  • Big sweeps: streamAllEventsByType iterates the log memory-bounded : use it for exports and audits instead of loading everything.
  • Sensitive data: sensitive: true fields must be ciphertext-at-rest (pii/encrypted, boot-validated). See how events work and crypto-shredding.

That’s the whole ladder: r.crud for nouns, events and projections for verbs, upcasters and snapshots for time, MSPs and rebuilds for scale. Each level stands on the ones below it, and the log underneath never forgot a thing.