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.
The Mapping
Section titled “The Mapping”| DDD / CQRS Concept | Ironflow Implementation |
|---|---|
| Write model | Entity streams — append events, enforce invariants via expectedVersion |
| Read model | Managed projections — pure reducers that build query-optimized views |
| Event bus | Ironflow’s internal event distribution (automatic) |
| Eventual consistency | Projections update asynchronously as events arrive |
| Side-effect handlers | External projections — sync to search indexes, caches, third-party systems |
Write Side: Entity Streams
Section titled “Write Side: Entity Streams”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 aggregateawait 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.
Read Side: Managed Projections
Section titled “Read Side: Managed Projections”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 } }External Projections: Side Effects
Section titled “External Projections: Side Effects”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});Idempotency in Projection Handlers
Section titled “Idempotency in Projection Handlers”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.
Rebuilding Projections
Section titled “Rebuilding Projections”When event schemas change or bugs are fixed in projection logic, you may need to rebuild projections from scratch. The rebuild process:
- Reset state — clear the current materialized view
- Replay events — stream all historical events through the handler
- 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 streamconst { events } = await ironflow.streams.read("order-123");for (const event of events) { await projection.handler(event); // Idempotent handler essential here}When to Introduce CQRS
Section titled “When to Introduce CQRS”Not every entity needs separate read and write models. Start simple and add projections when complexity demands it:
| Situation | Approach |
|---|---|
| Simple entity, few query patterns | Read directly from the stream — no projection needed |
| Multiple views of the same data | Add managed projections for each view |
| High-frequency reads, low-frequency writes | Add a projection to avoid repeated stream reads |
| External system sync | Add an external projection |
Key Takeaways
Section titled “Key Takeaways”- 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.