noddde

Projections

Building read-optimized views from event streams using the `on` map, query handlers, and defineProjection.

Projections are the read side of your domain. They consume events produced by aggregates and transform them into purpose-specific data structures called views (or read models) that are optimized for querying. For background on why the read and write sides are separated, see CQRS and Event Sourcing.

Why Projections?

An aggregate's state is structured to enforce business rules -- not to serve queries efficiently. A bank account aggregate tracks balance, availableBalance, and a transactions array because those are needed for authorization decisions. But a dashboard showing accounts sorted by balance, a search across transactions by merchant, or a spending analytics view all need different data structures.

Projections solve this by building query-specific views from the event stream. Adding a new read model means writing a new projection. The write model is never touched.

Scaffold with the CLI.

  • New projection: noddde new projection OrderSummary
  • Add a query: noddde add query ListOrders --projection order-summary
  • Add an event reducer: noddde add event-handler OrderShipped --projection order-summary

The add commands wire themselves into the projection definition. See the CLI Reference for details.

The Event Flow

When a command is dispatched, the following sequence connects the write side to projections:

  1. The aggregate's decide handler processes the command and returns events
  2. Events are persisted (via EventSourcedAggregatePersistence or equivalent)
  3. Each event is published to the EventBus
  4. Registered projections receive the events and update their views via event handlers
  5. Queries read from the projection views via query handlers
Command --> Aggregate --> Events --> EventBus --> Projection on Handlers --> View
                                                                            |
                                                             Query --> QueryHandler

The ProjectionTypes Bundle

Every projection is parameterized by a ProjectionTypes bundle -- a single named type that captures the projection's type universe:

type MyProjectionDef = {
  events: MyEvent; // The event union this projection handles
  queries: MyQuery; // The query union this projection answers
  view: MyView; // The view model this projection builds
  infrastructure: MyInfra; // Dependencies available to query handlers
  viewStore: MyViewStore; // (optional) Type hint for { views } injection into query handlers
};

The first four fields are required. The viewStore field is a type-level hint only — it is not used at runtime inside the projection definition itself. The actual view store instance is provided via DomainWiring.projections in wireDomain. When present, it enables typed { views } injection into query handlers. See View Persistence for details.

This follows the same pattern as AggregateTypes in defining aggregates -- instead of threading positional generics, you declare a single named type.

Defining a Projection

Use defineProjection to create a projection with full type inference. The recommended pattern is to extract event handlers and query handlers into separate files, typed with InferProjectionEventHandler and InferProjectionQueryHandler, then import them into the projection definition.

Each event handler lives in its own file inside an on-entries/ directory, and each query handler in a query-handlers/ directory. The Infer* helpers derive the exact type from your ProjectionTypes bundle and an event/query name.

types.ts
import type { ViewStore, DefineQueries } from "@noddde/core";

// View model -- read-optimized, display-focused
export type BankAccountView = {
  id: string;
  balance: number;
  transactions: {
    id: string;
    timestamp: Date;
    amount: number;
    status: "processed" | "declined" | "authorized";
  }[];
};

// Queries this projection serves
export type BankAccountQuery = DefineQueries<{
  GetBankAccountById: {
    payload: { id: string };
    result: BankAccountView | null;
  };
}>;

// The ProjectionTypes bundle
export type BankAccountProjectionDef = {
  events: BankAccountEvent;
  queries: BankAccountQuery;
  view: BankAccountView;
  infrastructure: BankingInfrastructure;
  viewStore: ViewStore<BankAccountView>;
};
on-entries/on-bank-account-created.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const onBankAccountCreated: InferProjectionEventHandler<
  BankAccountProjectionDef,
  "BankAccountCreated"
> = {
  id: (event) => event.payload.id,
  reduce: (event) => ({
    id: event.payload.id,
    balance: 0,
    transactions: [],
  }),
};
on-entries/on-transaction-processed.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const onTransactionProcessed: InferProjectionEventHandler<
  BankAccountProjectionDef,
  "TransactionProcessed"
> = {
  id: (event) => event.payload.accountId,
  reduce: (event, view) => ({
    ...view,
    balance: view.balance + event.payload.amount,
    transactions: [
      ...view.transactions,
      {
        id: event.payload.id,
        timestamp: event.payload.timestamp,
        amount: event.payload.amount,
        status: "processed" as const,
      },
    ],
  }),
};
query-handlers/handle-get-bank-account-by-id.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const handleGetBankAccountById: InferProjectionQueryHandler<
  BankAccountProjectionDef,
  "GetBankAccountById"
> = async (query, { views }) => (await views.load(query.id)) ?? null;

Then the projection definition imports them all:

projection.ts
import { defineProjection } from "@noddde/core";
import type { BankAccountProjectionDef } from "./types";
import { onBankAccountCreated } from "./on-entries/on-bank-account-created";
import { onTransactionProcessed } from "./on-entries/on-transaction-processed";
import { onTransactionDeclined } from "./on-entries/on-transaction-declined";
import { handleGetBankAccountById } from "./query-handlers/handle-get-bank-account-by-id";

export const BankAccountProjection = defineProjection<BankAccountProjectionDef>(
  {
    on: {
      BankAccountCreated: onBankAccountCreated,
      TransactionProcessed: onTransactionProcessed,
      TransactionDeclined: onTransactionDeclined,
    },

    queryHandlers: {
      GetBankAccountById: handleGetBankAccountById,
    },
  },
);

Inline handlers

For simple projections, inline handlers are still valid:

export const BankAccountProjection = defineProjection<BankAccountProjectionDef>(
  {
    on: {
      // Inline is fine for trivial reducers
      BankAccountCreated: {
        id: (event) => event.payload.id,
        reduce: (event) => ({
          id: event.payload.id,
          balance: 0,
          transactions: [],
        }),
      },
      // But extract complex handlers to separate files
      TransactionProcessed: onTransactionProcessed,
    },
    queryHandlers: {
      GetBankAccountById: handleGetBankAccountById,
    },
  },
);

Inline handlers work for trivial cases, but extracted handlers with InferProjectionEventHandler and InferProjectionQueryHandler are the recommended pattern. Each handler gets its own file, its own tests, and a single type annotation that validates the entire shape.

defineProjection is an identity function -- it returns the same object you pass in, but with full type inference applied. The framework uses the ProjectionTypes bundle to narrow event types inside each handler and validate query handler return types.

Event Handlers (the on Map)

The on map contains one entry per event the projection cares about. Each entry is an object with two fields:

  • id (optional) -- A function that extracts the view instance ID from the event. Required when the projection uses automatic view persistence.
  • reduce -- Receives the full event (not just the payload) and the current view, then returns the updated view.

Whether inline or extracted, the shape is the same. When extracted, use InferProjectionEventHandler to type the entry:

on-entries/on-bank-account-created.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const onBankAccountCreated: InferProjectionEventHandler<
  BankAccountProjectionDef,
  "BankAccountCreated"
> = {
  id: (event) => event.payload.id,
  reduce: (event, view) => ({
    ...view,
    id: event.payload.id,
  }),
};

Key properties of event handlers:

  • Automatic type narrowing -- Inside each handler, event is narrowed to the specific event variant (e.g., Extract<BankAccountEvent, { name: "BankAccountCreated" }>). No switch statements or type guards needed.
  • Partial event mapping -- Only include events the projection cares about. Unhandled events are silently ignored. No more noop pass-throughs.
  • Can be async -- reduce may return Promise<View> when it needs to perform asynchronous work.
  • Receive full events -- Unlike aggregate evolve handlers that receive just the payload, projection reducers receive the complete event object (with name and payload).
  • Can delete the view -- Reducers may return the DeleteView sentinel from @noddde/core to instruct the engine to call viewStore.delete(viewId) instead of viewStore.save. The full reducer return type is TView | typeof DeleteView | Promise<TView | typeof DeleteView>.

Why an on Map?

A map of per-event functions has two advantages over a single (view, event) => view function:

  1. Automatic type narrowing -- Each handler gets the specific event type, not the union.
  2. Event filtering -- Only subscribe to events you care about. Unhandled events are silently ignored.

Similarity to Evolve Handlers

The on map pattern mirrors the evolve handler pattern used in aggregates. Both are maps of functions that transform state based on events. The difference is purpose:

  • Evolve handlers rebuild the aggregate's internal state for command processing
  • Projection reducers build a query-optimized view for the read model

Deleting Views Conditionally

Because the reducer return type is TView | typeof DeleteView | Promise<TView | typeof DeleteView>, a single handler can choose between updating and deleting based on event content:

on-entries/on-item-deactivated.ts
import { DeleteView } from "@noddde/core";
import type { InferProjectionEventHandler } from "@noddde/core";
import type { ItemProjectionDef } from "../types";

export const onItemDeactivated: InferProjectionEventHandler<
  ItemProjectionDef,
  "ItemDeactivated"
> = {
  id: (event) => event.payload.id,
  reduce: (event, view) =>
    event.payload.permanent
      ? DeleteView
      : { ...view, status: "inactive" as const },
};

The engine awaits the return value and routes DeleteView to viewStore.delete and any view object to viewStore.save. Deletion is idempotent — see View Persistence: Deleting Views for the full contract.

Query Handlers

The queryHandlers map contains handlers for serving queries from the projection's view. When the projection's ProjectionTypes bundle includes a viewStore type hint and the domain configuration provides the corresponding view store, handlers automatically receive { views } -- a typed view store instance -- in their infrastructure parameter.

Extracted query handlers use InferProjectionQueryHandler to derive the full function type:

query-handlers/handle-get-bank-account-by-id.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const handleGetBankAccountById: InferProjectionQueryHandler<
  BankAccountProjectionDef,
  "GetBankAccountById"
> = async (query, { views }) => (await views.load(query.id)) ?? null;

The views object is typed to the projection's view store, giving you access to both the base save/load methods and any custom query methods you defined on the store interface. The handler's return type is validated against the result type declared in the DefineQueries map.

Query handlers are optional. A projection may handle events (updating a view) without directly serving queries.

Type Inference Helpers

noddde provides Infer* helpers at two levels. The handler-level helpers are the most commonly used -- they type extracted event handlers and query handlers in separate files:

import type {
  InferProjectionEventHandler,
  InferProjectionQueryHandler,
} from "@noddde/core";

// Types the { id?, reduce } object for a specific event
type Handler = InferProjectionEventHandler<
  BankAccountProjectionDef,
  "TransactionProcessed"
>;

// Types the query handler function (with { views } injection)
type QHandler = InferProjectionQueryHandler<
  BankAccountProjectionDef,
  "GetBankAccountById"
>;

Definition-level helpers extract types from the built projection:

import type {
  InferProjectionView,
  InferProjectionEvents,
  InferProjectionQueries,
  InferProjectionInfrastructure,
} from "@noddde/core";

type View = InferProjectionView<typeof BankAccountProjection>; // BankAccountView
type Events = InferProjectionEvents<typeof BankAccountProjection>; // BankAccountEvent
type Queries = InferProjectionQueries<typeof BankAccountProjection>; // BankAccountQuery
type Infra = InferProjectionInfrastructure<typeof BankAccountProjection>; // BankingInfrastructure

These are useful when other modules need to reference projection types without importing the underlying type aliases directly. See Type Inference Helpers for the full list.

Multiple Projections from the Same Events

A single stream of events can feed multiple projections, each optimized for a different query pattern. Each projection extracts its own event handler for the same event:

balance/on-entries/on-transaction-processed.ts
export const onTransactionProcessed: InferProjectionEventHandler<
  BalanceDef,
  "TransactionProcessed"
> = {
  id: (event) => event.payload.accountId,
  reduce: (event, view) => ({
    ...view,
    balance: view.balance + event.payload.amount,
  }),
};
history/on-entries/on-transaction-processed.ts
export const onTransactionProcessed: InferProjectionEventHandler<
  HistoryDef,
  "TransactionProcessed"
> = {
  id: (event) => event.payload.accountId,
  reduce: (event, view) => ({
    ...view,
    transactions: [
      ...view.transactions,
      {
        id: event.payload.id,
        amount: event.payload.amount,
        merchant: event.payload.merchant,
      },
    ],
  }),
};

Both projections consume the same TransactionProcessed event but build entirely different views. Each handler is typed against its own ProjectionTypes bundle.

Cross-Aggregate Projections

A projection is not limited to events from a single aggregate. A single projection can consume events from multiple aggregate types by using a union in the events field:

type CrossDomainProjectionDef = {
  events: BankAccountEvent | AuditEvent;
  queries: ActivityQuery;
  view: ActivityFeedView;
  infrastructure: AppInfrastructure;
};

This is useful for building views that span multiple domain concepts, such as an activity feed combining events from several aggregates.

Registering Projections

Projections are registered in the readModel.projections map of the domain configuration:

import { defineDomain, wireDomain } from "@noddde/engine";

const bankingDomain = defineDomain({
  writeModel: {
    aggregates: { BankAccount },
  },
  readModel: {
    projections: {
      // ORM-backed factory — enables transactional auto-persistence
      // and { views } injection into query handlers.
      AccountBalance: {
        projection: AccountBalanceProjection,
        viewStore: new PrismaAccountBalanceViewStoreFactory(prisma),
      },
      // In-memory factory — suitable for development and tests.
      Audit: {
        projection: AuditProjection,
        viewStore: new InMemoryViewStoreFactory<AuditView>(),
      },
      // Without view store — query-only or uses custom infra.
      TransactionHistory: TransactionHistoryProjection,
    },
  },
});

const domain = await wireDomain(bankingDomain, {
  /* wiring */
});

The key in the projections map (e.g., "AccountBalance") is the projection's name. It does not need to match the aggregate name. Passing { projection, viewStore } connects the projection to a view store from your infrastructure, enabling automatic view persistence and { views } injection into query handlers.

Event Delivery Guarantees

The EventBus implementation determines delivery guarantees:

  • EventEmitterEventBus (built-in) -- In-process, sequentially awaited delivery. Events are delivered immediately after persistence. If a handler throws, the error propagates. Suitable for single-process applications and testing.
  • Custom implementations -- You can implement asynchronous delivery, at-least-once guarantees, or distributed event buses by providing your own EventBus implementation.

Testing Reducers

Because reducers are pure functions, testing them is straightforward -- call the function with inputs and assert the output. With extracted handlers, you can import and test each handler independently:

import { describe, it, expect } from "vitest";
import { onBankAccountCreated } from "./on-entries/on-bank-account-created";
import { onTransactionProcessed } from "./on-entries/on-transaction-processed";

describe("BankAccountProjection handlers", () => {
  const emptyView = { id: "", balance: 0, transactions: [] };

  it("initializes the view on BankAccountCreated", () => {
    const result = onBankAccountCreated.reduce(
      { name: "BankAccountCreated", payload: { id: "acc-123" } },
      emptyView,
    );

    expect(result.id).toBe("acc-123");
    expect(result.balance).toBe(0);
  });

  it("adds to balance on TransactionProcessed", () => {
    const view = { id: "acc-123", balance: 100, transactions: [] };

    const result = onTransactionProcessed.reduce(
      {
        name: "TransactionProcessed",
        payload: {
          id: "txn-1",
          timestamp: new Date(),
          amount: 50,
          merchant: "Store",
        },
      },
      view,
    );

    expect(result.balance).toBe(150);
    expect(result.transactions).toHaveLength(1);
  });
});

No mocks needed. No infrastructure setup. Just inputs and outputs.

View Persistence

noddde provides view stores — typed persistence interfaces for per-entity view management. Projections use id extractors inside each on entry to identify which view instance to update, and the view store is provided via the domain configuration to automatically persist views when events arrive.

A ViewStore<TView> provides save(viewId, view) and load(viewId) methods. You can extend it with custom query methods for advanced filtering:

import type { ViewStore } from "@noddde/core";

interface BankAccountViewStore extends ViewStore<BankAccountView> {
  findByBalanceRange(min: number, max: number): Promise<BankAccountView[]>;
}

Identity Extractors

To enable automatic view persistence, provide an id function inside each relevant on entry. The id function extracts the view instance ID from the event so the engine knows which view to load and update:

on: {
  BankAccountCreated: {
    id: (event) => event.payload.id,  // view instance ID
    reduce: (event) => ({ id: event.payload.id, balance: 0, transactions: [] }),
  },
  TransactionProcessed: {
    id: (event) => event.payload.accountId,  // same view, different event
    reduce: (event, view) => ({
      ...view,
      balance: view.balance + event.payload.amount,
    }),
  },
},

When a handler omits id and a view store is configured, the engine defaults to event.metadata.aggregateId and logs a warning at startup. This works for the common case where each view maps 1:1 to an aggregate instance. If your view is keyed differently (e.g., by date, by a related entity ID), you must provide an explicit id extractor — otherwise the wrong view instance will be loaded and updated.

The view store itself is provided in the domain configuration (not in the projection definition). When the engine handles an event, it automatically:

  1. Extracts the view ID from on[eventName].id(event)
  2. Loads the current view from the view store (falling back to initialView if not found)
  3. Runs on[eventName].reduce(event, view) to produce the new view (or DeleteView)
  4. If the awaited return value is DeleteView, calls viewStore.delete(viewId); otherwise calls viewStore.save(viewId, newView)

Query Handler Views Injection

When a projection's ProjectionTypes bundle declares a viewStore type hint and the domain configuration provides the corresponding view store, query handlers automatically receive { views } in their infrastructure parameter, typed to the projection's view store. Extracted query handlers use InferProjectionQueryHandler to get this injection typed automatically:

query-handlers/handle-get-accounts-in-range.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { BankAccountProjectionDef } from "../types";

export const handleGetAccountsInRange: InferProjectionQueryHandler<
  BankAccountProjectionDef,
  "GetAccountsInRange"
> = (query, { views }) => views.findByBalanceRange(query.min, query.max); // custom method on the view store

Consistency Modes

Projections support two consistency modes via the consistency field:

  • "eventual" (default): Views are updated asynchronously after the command's unit of work is committed and events are dispatched via the event bus.
  • "strong": View updates are enlisted in the same unit of work as the originating command. If the command fails, view updates are rolled back atomically.
const projection = defineProjection<Def>({
  // ...
  consistency: "strong",
});

For more details, see View Persistence.

Next Steps

On this page