- Event Sourcing & DDD
- Projections
Projections
Projections build read-optimized materialized views from event streams. Instead of reading an entity’s full event history every time, a projection continuously processes events and maintains a derived state you can query instantly.
How It Works
Projections are a split responsibility between your SDK worker and the Ironflow server:
- Your app (SDK) — defines the projection handler and runs it in a streaming or poll loop
- Ironflow server — stores state, manages NATS consumers, serves queries to your frontend
The SDK registers the projection, receives events (via real-time streaming or polling), runs your handler locally, and saves the resulting state back to the server. The server never executes your handler logic — it just feeds events and persists whatever state the SDK sends back.
This means your projection logic lives in your codebase, versioned with your app, while the server handles durability, partitioning, lag tracking, and real-time distribution to connected clients.
Two Modes
| Mode | State location | Handler type | Use case |
|---|---|---|---|
| Managed | Ironflow DB | Pure reducer (state, event) => newState | Dashboards, aggregations, read models |
| External | Your database | Side-effect handler (event) => void | Search indexes, cache warming, third-party sync |
Mode is auto-detected: if you provide initialState, it’s managed. Otherwise it’s external. You can also set mode explicitly.
Managed Projections
A managed projection is a pure reducer — it takes the current state and an event, and returns the new state. Ironflow stores the materialized state in its database.
import { createProjection } from "@ironflow/node";
const orderStats = createProjection({ name: "order-stats", events: ["order.created", "order.completed"], handler: (state, event) => { switch (event.name) { case "order.created": return { ...state, totalOrders: state.totalOrders + 1, totalAmount: state.totalAmount + event.data.amount, }; case "order.completed": return { ...state, completedOrders: state.completedOrders + 1 }; default: return state; } }, initialState: () => ({ totalOrders: 0, completedOrders: 0, totalAmount: 0, }),});orderStats := ironflow.CreateProjection(ironflow.ProjectionConfig{ Name: "order-stats", Events: []string{"order.created", "order.completed"}, Handler: func(state map[string]any, event ironflow.ProjectionEvent, ctx ironflow.ProjectionContext) (map[string]any, error) { // Build a fresh map — never mutate the state argument. next := map[string]any{ "totalOrders": toFloat(state["totalOrders"]), "completedOrders": toFloat(state["completedOrders"]), "totalAmount": toFloat(state["totalAmount"]), } switch event.Name { case "order.created": next["totalOrders"] = toFloat(next["totalOrders"]) + 1 next["totalAmount"] = toFloat(next["totalAmount"]) + toFloat(event.Data["amount"]) case "order.completed": next["completedOrders"] = toFloat(next["completedOrders"]) + 1 } return next, nil }, InitialState: func() map[string]any { return map[string]any{"totalOrders": 0, "completedOrders": 0, "totalAmount": 0} },})Querying State
Once a managed projection is running, query its state via the REST API or SDK:
import { ironflow } from "@ironflow/browser";
const result = await ironflow.getProjection<OrderStats>("order-stats");console.log(result.state); // { totalOrders: 42, completedOrders: 38, totalAmount: 12500 }console.log(result.mode); // "managed"result, err := client.Projections().Get(ctx, "order-stats")if err != nil { log.Fatal(err) }fmt.Println(result.State, result.LastEventSeq)from ironflow import AsyncIronflowRPCfrom ironflow.rpc import v1
async with AsyncIronflowRPC(server_url="http://localhost:9123") as rpc: result = await rpc.projections.get(v1.GetProjectionRequest(name="order-stats")) state = result.state_value if result.state_value is not None else result.state print(state.to_python() if state is not None else None) print(result.registry.last_event_seq if result.registry else 0)The RPC returns reducer state in state_value or state. Registry metadata is in registry; both state fields are absent before the first event.
curl -X POST http://localhost:9123/ironflow.v1.ProjectionService/GetProjection -H 'Content-Type: application/json' -d '{"name":"order-stats"}'Defining a projection is Go/TypeScript only
The Python SDK can read, rebuild, pause, resume, and delete projections, but it
cannot define one — a reducer is worker-runtime code and there is no
projections_create method. Write the projection in Go or TypeScript and
operate it from Python.
External Projections
An external projection runs side effects — writing to your own database, updating a search index, or syncing to a third-party service. Ironflow only tracks the cursor position (which events have been processed).
import { createProjection } from "@ironflow/node";
const searchIndex = createProjection({ name: "employee-search-index", events: ["employee.*"], handler: async (event, ctx) => { // Side effect — write to your own system await elasticsearch.index({ index: "employees", id: event.data.id, body: event.data, }); ctx.logger.info(`Indexed employee ${event.data.id}`); }, // No initialState → auto-detected as external mode});searchIndex := ironflow.CreateProjection(ironflow.ProjectionConfig{ Name: "employee-search-index", Events: []string{"employee.*"}, Mode: ironflow.ProjectionModeExternal, Handler: func(state map[string]any, event ironflow.ProjectionEvent, ctx ironflow.ProjectionContext) (map[string]any, error) { // Side effect — write to your own system err := elasticsearch.Index("employees", event.Data["id"], event.Data) return nil, err }, // No InitialState → external mode})Registering Projections
Add projections to your worker configuration. Projections need a persistent poll loop, so they run in pull mode (workers), not push mode (serverless).
import { createWorker } from "@ironflow/node";
const worker = createWorker({ functions: [processOrder, sendEmail], projections: [orderStats, searchIndex],});
// Projections register and start polling as the worker starts. start() blocks// until the worker is stopped — it does not resolve once polling is under way,// so put any setup code before it, not after.await worker.start();worker := ironflow.NewWorker(ironflow.WorkerConfig{ ServerURL: "http://localhost:9123", Functions: []ironflow.Function{ProcessOrder, SendEmail}, Projections: []ironflow.Projection{orderStats, searchIndex},})
err := worker.Run(ctx)// Projections automatically register and start pollingIf you pass projections to serve() (push mode), a warning is logged. Projections require a persistent process — use createWorker() instead.
Streaming vs Polling
The projection runner supports two event delivery strategies:
| Strategy | How it works | Latency | Availability |
|---|---|---|---|
| Streaming | Opens a persistent ConnectRPC server-stream that pushes events in real-time | Milliseconds | Ironflow ≥ 0.15 |
| Polling | Periodically calls PollProjectionEvents with exponential backoff (1 s → 2 s → 4 s → 8 s → 10 s, reset on success) | Seconds | All versions |
The worker automatically tries streaming first. If the server returns 404 or 501 (older version), it falls back to polling transparently — no code changes required.
Micro-batching (streaming mode)
In streaming mode the runner groups incoming events into micro-batches to amortize state saves:
- Batch size trigger — flushes when the batch reaches
batchSizeevents (default 100) - Time trigger — flushes 100 ms after the first event in the batch, whichever comes first
This means a burst of 50 events results in a single state save instead of 50 individual writes.
Reconnection
If the stream ends cleanly (e.g., server restart), the runner reconnects after a 1-second delay. On stream errors the delay is 2 seconds. Either way it resumes from the last acknowledged position. No events are lost.
Partitioned Projections
Partition projections by a key extracted from event data. Each partition maintains its own independent state — useful for per-customer, per-tenant, or per-entity projections.
const customerStats = createProjection({ name: "customer-stats", events: ["order.created", "order.completed"], partitionKey: "$.data.customerId", handler: (state, event) => ({ ...state, orderCount: state.orderCount + 1, totalSpend: state.totalSpend + (event.data.amount ?? 0), }), initialState: () => ({ orderCount: 0, totalSpend: 0 }),});customerStats := ironflow.CreateProjection(ironflow.ProjectionConfig{ Name: "customer-stats", Events: []string{"order.created", "order.completed"}, PartitionKey: "$.data.customerId", Handler: func(state map[string]any, event ironflow.ProjectionEvent, ctx ironflow.ProjectionContext) (map[string]any, error) { // Build a fresh map — never mutate the state argument. return map[string]any{ "orderCount": toFloat(state["orderCount"]) + 1, "totalSpend": toFloat(state["totalSpend"]) + toFloat(event.Data["amount"]), }, nil }, InitialState: func() map[string]any { return map[string]any{"orderCount": 0, "totalSpend": 0} },})Query a specific partition:
const result = await ironflow.getProjection("customer-stats", { partition: "cust-456",});result = await rpc.projections.get(v1.GetProjectionRequest(name="customer-stats", partition="cust-456"))# Partition enumeration remains on the REST client.parts = client.projections_list_partitions("customer-stats", limit=100)curl -X POST http://localhost:9123/ironflow.v1.ProjectionService/GetProjection -H 'Content-Type: application/json' -d '{"name":"customer-stats","partition":"cust-456"}'The partitionKey uses dot-notation JSONPath to extract the key from event data. For example, $.data.customerId extracts customerId from { "data": { "customerId": "cust-456" } }.
Real-Time Subscriptions
Subscribe to projection state updates in the browser. Events are pushed via WebSocket whenever the projection state changes.
import { ironflow } from "@ironflow/browser";
// Subscribe to all updates for a projectionconst sub = await ironflow.subscribeToProjection<OrderStats>("order-stats", { onUpdate: (state, event) => { console.log("Projection updated:", state); }, onError: (error) => console.error(error),});
// Subscribe to a specific partitionconst partSub = await ironflow.subscribeToProjection( "customer-stats", { onUpdate: (state) => console.log("Customer updated:", state), }, { partition: "cust-456" },);
// Clean upsub.unsubscribe();partSub.unsubscribe();// The Go SDK uses the worker's built-in projection runner for server-side// processing. For real-time browser subscriptions, use the TypeScript browser// SDK or connect directly via WebSocket to the subscription endpoint:// ws://localhost:9123/ws?topic=system.projection.order-stats.>Rebuilding Projections
Rebuild a projection to reprocess all events from the beginning. This is useful when you change your handler logic or need to recover from errors.
await client.projections.rebuild("order-stats");job, err := client.Projections().Rebuild(ctx, "order-stats")if err != nil { log.Fatal(err) }fmt.Println(job.Status)job = await rpc.projections.rebuild(v1.RebuildProjectionRequest(name="order-stats"))status = await rpc.projections.get_rebuild_job(v1.GetRebuildJobRequest(name="order-stats"))if status.job is not None: print(status.job.status) if status.job.status == "running": await rpc.projections.cancel_rebuild(v1.CancelRebuildRequest(name="order-stats"))
await rpc.projections.rebuild(v1.RebuildProjectionRequest(name="order-stats", from_event_id="evt_123"))Python requests use typed fields such as from_event_id, to_event_id, dry_run, and partition.
curl -X POST http://localhost:9123/ironflow.v1.ProjectionService/RebuildProjection -H 'Content-Type: application/json' -d '{"name":"order-stats"}'What happens during a rebuild:
- Projection status changes to
rebuilding - All stored state is deleted (managed mode)
- The rebuild manager resolves a start cursor (
0for full rebuild, or thenats_seqoffromEventIdfor partial) and records progress markers on the projection registry - The SDK continues its normal stream/poll loop; the server replays historical events from the cursor with
phase=scan(on spansprojection.rebuild.batchand theironflow_projection_rebuild_events_applied_totalcounter) while live events keep flowing - Status returns to
activeonce the rebuild catches up to the live cursor
For external projections, you’re responsible for clearing your own data store before triggering a rebuild.
Pause and Resume
Temporarily pause a projection to stop it from processing new events, then resume when ready.
if err := client.Projections().Pause(ctx, "order-stats"); err != nil { log.Fatal(err) }if err := client.Projections().Resume(ctx, "order-stats"); err != nil { log.Fatal(err) }await rpc.projections.pause(v1.PauseProjectionRequest(name="order-stats"))await rpc.projections.resume(v1.ResumeProjectionRequest(name="order-stats"))# Pause a projectioncurl -X POST http://localhost:9123/ironflow.v1.ProjectionService/PauseProjection -H 'Content-Type: application/json' -d '{"name":"order-stats"}'
# Resume a paused projectioncurl -X POST http://localhost:9123/ironflow.v1.ProjectionService/ResumeProjection -H 'Content-Type: application/json' -d '{"name":"order-stats"}'A projection can only be paused when it is active or rebuilding. It can only be resumed when paused.
Deleting Projections
Unregister a projection and remove its stored state:
if err := client.Projections().Delete(ctx, "order-stats"); err != nil { log.Fatal(err) }client.projections_delete("order-stats")curl -X DELETE http://localhost:9123/api/v1/projections/order-statsProjection Status
Check the status of a projection:
const status = await ironflow.getProjectionStatus("order-stats");console.log(status.status); // "active" | "rebuilding" | "paused" | "error"console.log(status.lastEventSeq); // last processed event sequenceconsole.log(status.errorMessage); // error details if status is "error"status, err := client.Projections().GetStatus(ctx, "order-stats")if err != nil { log.Fatal(err) }fmt.Println(status.Status)status = await rpc.projections.get_status(v1.GetProjectionStatusRequest(name="order-stats"))print(status.status, status.last_event_seq, status.error_message)curl -X POST http://localhost:9123/ironflow.v1.ProjectionService/GetProjectionStatus -H 'Content-Type: application/json' -d '{"name":"order-stats"}'Status Values
| Status | Description |
|---|---|
active | Processing events normally |
rebuilding | Reprocessing from the beginning |
paused | Temporarily stopped |
error | Failed — check errorMessage for details |
Listing Projections
List all registered projections:
const projections = await ironflow.listProjections();projections.forEach((p) => { console.log(`${p.name}: ${p.status} (${p.mode})`);});projections, err := client.Projections().List(ctx)if err != nil { log.Fatal(err) }for _, p := range projections { fmt.Println(p.Name, p.Status) }curl -X POST http://localhost:9123/ironflow.v1.ProjectionService/ListProjections -H 'Content-Type: application/json' -d '{}'SQL Projections
SQL projections are materialized, like handler-based projections — they process events. The difference is where the state lives and what writes it: a real SQL table inside Ironflow’s own database, written by a parameterized INSERT/UPDATE/DELETE per event instead of by a reducer function.
The table name is always proj_<projection name>. Applications never connect to that table directly; it is reached only through the API.
Create one with the ConnectRPC CreateSQLProjection RPC (there is no REST route for this):
await client.sqlProjections.create({ name: "active_orders", tableSql: ` CREATE TABLE proj_active_orders ( order_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, total NUMERIC(12,2) ); CREATE INDEX proj_active_orders_customer ON proj_active_orders (customer_id); `, events: ["order.placed", "order.cancelled"], eventHandlers: { "order.placed": "INSERT INTO proj_active_orders (order_id, customer_id, total) VALUES (:entity_id, :data.customerId, :data.total)", "order.cancelled": "DELETE FROM proj_active_orders WHERE order_id = :entity_id", },});Read it back with QuerySQLProjection — not POST /ironflow.v1.ProjectionService/GetProjection, which returns the projection’s registry entry and cursor, not its rows:
const result = await client.sqlProjections.query("active_orders", { where: "customer_id = 'cust-1'", orderBy: "total DESC", limit: 50,});SQL projections:
- Have type
"sql"in the projection listing (vs"sdk"for handler-based projections) - Own exactly one
proj_-prefixed table, named after the projection. The name must therefore be an unquoted SQL identifier — letters, digits and underscores — soactive_orders, notactive-orders. Handler-based ("sdk") projections have no such restriction. The DDL may also declare indexes on that table, andCREATE EXTENSION IF NOT EXISTS vector/pg_trgmbefore it (PostgreSQL only) - Cannot read or write any other table — the DDL, the event handlers, and the query clauses are each parsed against a closed grammar, and every value is bound rather than concatenated. In the DDL and in a handler that means a
(may only follow the projection’s own table, a SQL keyword that takes one, a type modifier, or a function on a closed list, so aDEFAULTor aVALUESentry cannot call something liketable_to_xml('other_table'::regclass, …)(#1689) - Return rows twice:
rowswith every value stringified, andtypedRowswith the SQL type preserved - Accept a fixed set of expressions in
whereandorderBybeyond ordinary filters —@@withplainto_tsquery, pgvector distance (<->,<=>,<#>), andts_rank(...)— so a vector or full-text read model can reach its index. Those need the PostgreSQL backend
See the browser SDK reference for the full where and orderBy grammar.
Configuration Options
| Option | Default | Description |
|---|---|---|
name | (required) | Unique projection name |
events | (required) | Event names to subscribe to. Both * and > match the remainder of the subject (order.* and order.> are equivalent — Ironflow rewrites * to > before creating the NATS consumer). |
handler | (required) | Handler function (reducer for managed, side-effect for external) |
initialState | — | Factory function returning initial state (triggers managed mode) |
mode | auto-detected | "managed" or "external" |
partitionKey | — | JSONPath for partition key extraction (e.g., "$.data.customerId") |
maxRetries | 3 | Accepted and registered, but not yet enforced by either runner — see Error Handling |
batchSize | 100 | Number of events to fetch per poll |
HTTP API Reference
| Method | Endpoint | Description |
|---|---|---|
POST | /ironflow.v1.ProjectionService/ListProjections | List all projections |
POST | /ironflow.v1.ProjectionService/GetProjection | Get projection state |
POST | /ironflow.v1.ProjectionService/GetProjectionStatus | Get projection status |
GET | /api/v1/projections/{name}/partitions | List partitions (partitioned mode) |
POST | /ironflow.v1.ProjectionService/WaitProjectionCatchup | Long-poll until the projection reaches a minimum NATS sequence (minSeq) |
POST | /ironflow.v1.ProjectionService/WaitProjectionCatchupBatch | Wait for several projections to catch up in one call |
POST | /ironflow.v1.ProjectionService/WaitForEvent | Block until a specific event has been applied by a projection |
POST | /ironflow.v1.ProjectionService/RebuildProjection | Trigger rebuild |
POST | /ironflow.v1.ProjectionService/GetRebuildJob | Get rebuild job progress |
POST | /ironflow.v1.ProjectionService/CancelRebuild | Cancel an in-progress rebuild |
POST | /ironflow.v1.ProjectionService/PauseProjection | Pause projection |
POST | /ironflow.v1.ProjectionService/ResumeProjection | Resume projection |
DELETE | /api/v1/projections/{name} | Delete projection |
JSON request fields for POST /ironflow.v1.ProjectionService/GetProjection (include name):
partition— Return state for a specific partition (default:__global__)
Error Handling
Handler-based ("sdk") and SQL projections fail in opposite directions — retry forever versus skip and record.
Handler-based projections. A throwing handler aborts the batch before the state save (managed) or the ack (external), so nothing advances and the same events are redelivered on the next poll. The runner logs the error and backs off (1 s → 10 s), then tries again — indefinitely. A poison event stalls the projection until you fix and redeploy the handler, or rebuild past it.
Two things the runner does not currently do, despite what the surrounding API suggests:
maxRetriesis accepted, defaulted to3, and sent to the server at registration, but no runner and no server path reads it. There is no per-event retry counter.- Neither runner flips the projection to
erroror writeserrorMessageon a handler failure, and neither treatsNonRetryableErrordifferently from any other throw.
SQL projections. These execute server-side, and a failing statement is handled the other way round: the event is logged, acked, and skipped so the cursor still advances — one poison event cannot stall the projection. The registry is flipped to error with errorMessage set to the first failure (event "<name>" (seq N): <err>), visible through ListProjections, POST /ironflow.v1.ProjectionService/GetProjectionStatus, and the dashboard. Later events keep processing.
Managed vs External — When to Use Which
| Scenario | Mode | Why |
|---|---|---|
| Dashboard aggregations | Managed | Query state directly from Ironflow |
| Search index | External | Data lives in Elasticsearch/Algolia |
| Cache warming | External | Data lives in Redis/Memcached |
| Reporting summaries | Managed | Simple reducer, queryable via API |
| Third-party sync | External | Side effects to external systems |
| Per-customer analytics | Managed + partitioned | Partitioned state in Ironflow |
Domain-Driven Design
Projections implement the Read Model side of CQRS — separate, query-optimized views derived from domain events. See CQRS with Projections for how this maps to Domain-Driven Design.