Skip to content

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

ts
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

ts
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:

ts
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:

ts
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):

ts
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:

ts
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

MIT licensed.