Skip to content

CQRS with Projections

Entity streams are your write model. Projections are your read models. Together, they implement CQRS — the separation of how you write data from how you read it.

DDD / CQRS ConceptIronflow Implementation
Write modelEntity streams — append events, enforce invariants via expectedVersion
Read modelManaged projections — pure reducers that build query-optimized views
Event busIronflow’s internal event distribution (automatic)
Eventual consistencyProjections update asynchronously as events arrive
Side-effect handlersExternal projections — sync to search indexes, caches, third-party systems

The write side is an entity stream. You append events and use expectedVersion to enforce invariants:

// Write side — append a domain event to the Order aggregate
await ironflow.streams.append("order-123", {
entityType: "order",
name: "order.placed",
data: { customerId: "cust-456", total: 59.98 },
}, { expectedVersion: 0 });

The write model doesn’t need to be queryable. Its job is to enforce business rules and record facts.

The read side is a managed projection — a pure reducer that processes events and builds a query-optimized view. Ironflow stores the materialized state and serves it via API.

import { createProjection } from "@ironflow/node";
const orderSummary = createProjection({
name: "order-summary",
events: ["order.placed", "order.paid", "order.shipped", "order.cancelled"],
handler: (state, event) => {
const order = state.orders[event.data.orderId] ?? {
id: event.data.orderId,
status: "unknown",
total: 0,
};
switch (event.name) {
case "order.placed":
order.status = "placed";
order.total = event.data.total;
order.customerId = event.data.customerId;
break;
case "order.paid":
order.status = "paid";
break;
case "order.shipped":
order.status = "shipped";
break;
case "order.cancelled":
order.status = "cancelled";
break;
}
return {
...state,
orders: { ...state.orders, [order.id]: order },
};
},
initialState: () => ({ orders: {} }),
});

Query the read model from your frontend:

import { ironflow } from "@ironflow/browser";
const result = await ironflow.getProjection("order-summary");
console.log(result.state.orders); // { "order-123": { status: "paid", total: 59.98 } }

Not all read models live in Ironflow. External projections sync events to your own systems — search indexes, caches, analytics databases:

const searchSync = createProjection({
name: "order-search-sync",
events: ["order.*"],
handler: async (event) => {
await elasticsearch.index({
index: "orders",
id: event.data.orderId,
body: { status: event.name.split(".")[1], ...event.data },
});
},
// No initialState → external mode
});

Projection handlers must be idempotent — processing the same event twice must produce the same result. This matters because:

  • Replay — when rebuilding projections, events are reprocessed from the beginning
  • Retries — transient failures cause automatic retries
  • Crash recovery — workers resume from the last checkpoint, potentially reprocessing recent events

Managed projections (pure reducers with initialState) are naturally idempotent — they replace state rather than accumulate side effects.

External projections require explicit idempotency. Use event IDs as idempotency keys:

const searchSync = createProjection({
name: "order-search-sync",
events: ["order.*"],
handler: async (event) => {
// Use event.id as document ID — upsert is idempotent
await elasticsearch.index({
index: "orders",
id: event.id, // Ensures idempotency on replay
body: {
orderId: event.data.orderId,
status: event.name.split(".")[1],
...event.data,
},
});
},
});

For external systems without upsert semantics, track processed event IDs in a separate store and skip already-seen events.

When event schemas change or bugs are fixed in projection logic, you may need to rebuild projections from scratch. The rebuild process:

  1. Reset state — clear the current materialized view
  2. Replay events — stream all historical events through the handler
  3. Resume normal operation — continue processing new events

For managed projections, Ironflow handles rebuilds internally. For external projections, you implement the replay logic:

// Rebuild external projection by reading entity stream
const { events } = await ironflow.streams.read("order-123");
for (const event of events) {
await projection.handler(event); // Idempotent handler essential here
}

Not every entity needs separate read and write models. Start simple and add projections when complexity demands it:

SituationApproach
Simple entity, few query patternsRead directly from the stream — no projection needed
Multiple views of the same dataAdd managed projections for each view
High-frequency reads, low-frequency writesAdd a projection to avoid repeated stream reads
External system syncAdd an external projection
  • Entity streams are the write model — append events, enforce invariants.
  • Projections are read models — pure reducers that build optimized views from events.
  • Eventual consistency is the trade-off — read models lag slightly behind writes.
  • Start without CQRS — add projections when query patterns diverge from write patterns.

For a step-by-step walkthrough — command, handler, stream, two projections, queries, rebuild — see CQRS + Event Sourcing.

For the full Projections API (partitioning, rebuilding, subscriptions, status), see the Projections guide.