- Event Sourcing & DDD
- Domain Modeling (DDD)
- CQRS with Projections
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 existing = state.orders[event.data.orderId] ?? { id: event.data.orderId, status: "unknown", total: 0, };
// Never mutate `existing` — the reducer must return fresh state. let order = existing; switch (event.name) { case "order.placed": order = { ...existing, status: "placed", total: event.data.total, customerId: event.data.customerId, }; break; case "order.paid": order = { ...existing, status: "paid" }; break; case "order.shipped": order = { ...existing, status: "shipped" }; break; case "order.cancelled": order = { ...existing, 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) have no side effects, but delivery is at-least-once, so the same event can reach a reducer more than once. Write the reducer to be deterministic and idempotent — the example above sets orders[id] and never adds to a counter. See the reducer contract.
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
Both kinds use the same call. For a managed projection, Ironflow clears the stored state and replays the events. For an external projection, you clear your own data store first, then Ironflow replays the events through your handler. An idempotent handler is essential here.
import { createClient } from "@ironflow/node";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
await client.projections.rebuild("order-search-sync");A rebuild replays only the events that event retention has kept. When IRONFLOW_EVENT_RETENTION_DAYS has pruned an event in the replay range, Ironflow refuses the rebuild instead of replaying a truncated history. See the Projections guide for the override.
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 |
CQRS is not the default for every entity. Compare the two styles before you choose:
| CRUD-style | CQRS-style |
|---|---|
| One model, one database, same shape for reads and writes | Two models: a write model (aggregate and stream) and a read model (projections) |
| Controllers call an ORM and return rows | HTTP dispatches commands. A command returns an acknowledgement, not state |
| Events, if any, are notifications after the fact | Events are the state: stored, folded and projected |
| Business rules live in the service layer | Invariants live in the aggregate decider |
| Read-your-own-writes is trivial (same transaction) | Reads are eventually consistent |
CQRS pays off with rich business rules, multiple read shapes, audit needs or long-running processes. It adds overhead to a form-and-table app with one read view. Most apps have pockets of each style.
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.