Projections + queries
When the events are the source of truth, a projection folds them into a read-shaped state and a query reads from it. So instead of querying your write tables, you keep a view that's shaped for how it's read and kept current by the events. That keeps reads fast and decoupled from writes — no event-sourcing ceremony required.
A projection
import { defineProjection } from "@nwire/forge";
import { OrderWasPlaced, OrderWasShipped } from "./events";
interface DashboardState {
byId: Record<string, { orderId: string; status: string; total: number }>;
totals: { placed: number; shipped: number };
}
export const ordersDashboard = defineProjection<DashboardState>(
"orders-dashboard",
({ when }) => {
when(OrderWasPlaced, (state, event) => ({
byId: {
...state.byId,
[event.orderId]: { orderId: event.orderId, status: "placed", total: event.total },
},
totals: { ...state.totals, placed: state.totals.placed + 1 },
}));
when(OrderWasShipped, (state, event) => {
const existing = state.byId[event.orderId];
if (!existing) return state;
return {
byId: { ...state.byId, [event.orderId]: { ...existing, status: "shipped" } },
totals: {
...state.totals,
placed: state.totals.placed - 1,
shipped: state.totals.shipped + 1,
},
};
});
},
{
description: "Order status dashboard.",
initial: () => ({ byId: {}, totals: { placed: 0, shipped: 0 } }),
},
);Each when reducer looks exactly like a workflow's listener, but it returns the next state instead of dispatching side effects.
A query
import { defineQuery } from "@nwire/forge";
import { z } from "zod";
import { ordersDashboard } from "./orders-dashboard.projection";
export const listOrders = defineQuery(ordersDashboard, {
name: "orders.list",
input: z.object({
status: z.enum(["placed", "shipped"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
execute: (state, { status, limit }) =>
Object.values(state.byId)
.filter((o) => !status || o.status === status)
.slice(0, limit),
});
export const orderTotals = defineQuery(ordersDashboard, {
name: "orders.totals",
input: z.object({}),
execute: (state) => state.totals,
});Calling queries
From a forge handler:
import { defineAction } from "@nwire/forge";
const listPlacedOrders = defineAction({
name: "orders.list-placed",
input: ListPlacedOrdersInput,
handler: async (ctx) => {
const orders = await ctx.query("orders.list", { status: "placed" });
return { orders };
},
});From an HTTP route, wire the query directly — it is its own handler:
import { get } from "@nwire/wires/http";
import { listOrders, orderTotals } from "./orders.queries";
app.wire(get("/orders"), listOrders);
app.wire(get("/orders/totals"), orderTotals);To read inside another handler, call ctx.query(queryDef, input):
const rows = await ctx.query(listOrders, {});In-memory vs persistent
The default InMemoryProjectionStore keeps the folded state in process memory — fine for dev, single-instance services, and tests. For production scale-out, swap it via the forge plugin's options or via a container binding:
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
import { mongoProjectionStore } from "@nwire/mongo";
const app = createApp({
appName: "orders",
registry: defineRegistry({
handlers: [listOrders, orderTotals],
projections: [ordersDashboard],
}),
plugins: [
...forgePlugins({ projectionStore: mongoProjectionStore({ uri: process.env.MONGO_URL! }) }),
],
});The projection's on reducers stay identical — only the store changes.
Why a projection (vs reading from the event log on every request)
Folding the event log per request works for low traffic. For a dashboard polled by many clients, that's wasted CPU. Projections fold incrementally: each event mutates state once; queries read the materialised state directly.
Trade-off: more memory or DB rows; eventually consistent (the projection lags the event by ms). For dashboards, queues, search indexes, that's the right trade.
Where to next
defineProjectionreference — full signature, multi-tenancy hooks, persistence overrides.defineQueryreference — schema + execute contract, caching.- Workflows — when an event needs to dispatch the next action (not just update state).