Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

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 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 } }

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) 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.

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

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.

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

CQRS is not the default for every entity. Compare the two styles before you choose:

CRUD-styleCQRS-style
One model, one database, same shape for reads and writesTwo models: a write model (aggregate and stream) and a read model (projections)
Controllers call an ORM and return rowsHTTP dispatches commands. A command returns an acknowledgement, not state
Events, if any, are notifications after the factEvents are the state: stored, folded and projected
Business rules live in the service layerInvariants 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.

  • 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.