noddde

View Persistence

Persisting projection views with ViewStore, auto-persistence via the on map, and choosing between eventual and strong consistency.

View persistence enables projections to automatically store and retrieve per-entity views using typed view stores. Instead of manually wiring event handlers to save views and writing boilerplate query handlers that delegate to repositories, you declare id extractors in the projection's on map and provide a viewStore in the domain configuration — the framework handles the rest.

The ViewStore Interface

ViewStore<TView> is the base persistence interface for projection views:

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

interface ViewStore<TView = any> {
  save(viewId: ID, view: TView): Promise<void>;
  load(viewId: ID): Promise<TView | undefined | null>;
  delete(viewId: ID): Promise<void>;
}
  • save persists a view instance, replacing any previously stored view for the given ID.
  • load retrieves a view by ID, returning undefined or null if not found.
  • delete removes a view by ID. Idempotent — deleting a non-existent view is a no-op and resolves successfully without throwing.
  • viewId uses the framework's ID type (string | number | bigint).

Extending with Custom Query Methods

The base interface provides save, load, and delete. For advanced filtering, extend it:

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

Custom methods are available in query handlers via the { views } injection. Extending ViewStore does not exempt the implementation from providing save, load, and delete.

Implementing a Custom ViewStore

Any class or object that satisfies the three-method contract is a valid view store. A minimal stub:

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

class MyViewStore<TView> implements ViewStore<TView> {
  async save(viewId: ID, view: TView): Promise<void> {
    /* ... */
  }
  async load(viewId: ID): Promise<TView | undefined> {
    /* ... */
  }
  async delete(viewId: ID): Promise<void> {
    /* Must NOT throw when the row is missing — treat absent rows as a no-op. */
  }
}

InMemoryViewStore

The engine provides InMemoryViewStore<TView> for development and testing:

import { InMemoryViewStore } from "@noddde/engine";

const store = new InMemoryViewStore<BankAccountView>();
await store.save("acc-1", { id: "acc-1", balance: 100 });
const view = await store.load("acc-1"); // { id: "acc-1", balance: 100 }
await store.delete("acc-1");
const after = await store.load("acc-1"); // undefined

It also includes convenience methods not on the base interface:

  • findAll() -- returns all stored views
  • find(predicate) -- filters views by a predicate function

For production, use ORM adapters or implement your own ViewStore.

Configuring View Persistence

To enable automatic view persistence on a projection, provide three things:

  1. viewStore type hint in your ProjectionTypes bundle (for typed { views } injection)
  2. id function in each on entry (tells the engine how to derive the view instance ID)
  3. viewStore factory in the domain configuration (provides the actual store instance)
types.ts
import type { ViewStore, DefineEvents, DefineQueries } from "@noddde/core";

export interface AccountView {
  id: string;
  balance: number;
}

export interface AccountViewStore extends ViewStore<AccountView> {
  findByBalanceRange(min: number, max: number): Promise<AccountView[]>;
}

export type AccountEvent = DefineEvents<{
  AccountCreated: { id: string; owner: string };
  DepositMade: { accountId: string; amount: number };
}>;

export type AccountQuery = DefineQueries<{
  GetAccountById: { payload: { id: string }; result: AccountView | null };
  GetAccountsInRange: {
    payload: { min: number; max: number };
    result: AccountView[];
  };
}>;

export type AccountProjectionDef = {
  events: AccountEvent;
  queries: AccountQuery;
  view: AccountView;
  infrastructure: {};
  viewStore: AccountViewStore; // Type hint for { views } injection
};
on-entries/on-account-created.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { AccountProjectionDef } from "../types";

export const onAccountCreated: InferProjectionEventHandler<
  AccountProjectionDef,
  "AccountCreated"
> = {
  id: (event) => event.payload.id,
  reduce: (event) => ({
    id: event.payload.id,
    balance: 0,
  }),
};
on-entries/on-deposit-made.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { AccountProjectionDef } from "../types";

export const onDepositMade: InferProjectionEventHandler<
  AccountProjectionDef,
  "DepositMade"
> = {
  id: (event) => event.payload.accountId,
  reduce: (event, view) => ({
    ...view,
    balance: view.balance + event.payload.amount,
  }),
};
query-handlers/handle-get-account-by-id.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { AccountProjectionDef } from "../types";

export const handleGetAccountById: InferProjectionQueryHandler<
  AccountProjectionDef,
  "GetAccountById"
> = async (query, { views }) => (await views.load(query.id)) ?? null;
query-handlers/handle-get-accounts-in-range.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { AccountProjectionDef } from "../types";

export const handleGetAccountsInRange: InferProjectionQueryHandler<
  AccountProjectionDef,
  "GetAccountsInRange"
> = (query, { views }) => views.findByBalanceRange(query.min, query.max);
projection.ts
import { defineProjection } from "@noddde/core";
import type { AccountProjectionDef } from "./types";
import { onAccountCreated } from "./on-entries/on-account-created";
import { onDepositMade } from "./on-entries/on-deposit-made";
import { handleGetAccountById } from "./query-handlers/handle-get-account-by-id";
import { handleGetAccountsInRange } from "./query-handlers/handle-get-accounts-in-range";

const AccountProjection = defineProjection<AccountProjectionDef>({
  on: {
    AccountCreated: onAccountCreated,
    DepositMade: onDepositMade,
  },

  queryHandlers: {
    GetAccountById: handleGetAccountById,
    GetAccountsInRange: handleGetAccountsInRange,
  },
});
domain.ts
// In domain wiring -- a ViewStoreFactory is provided here
import { defineDomain, wireDomain } from "@noddde/engine";
import { PrismaAccountViewStoreFactory } from "./infrastructure/prisma-account-view-store-factory";

const definition = defineDomain({
  writeModel: {
    aggregates: {
      /* ... */
    },
  },
  readModel: { projections: { Account: AccountProjection } },
});

const accountFactory = new PrismaAccountViewStoreFactory(prisma);

const domain = await wireDomain(definition, {
  projections: {
    Account: { viewStore: accountFactory },
  },
});

Deleting Views with DeleteView

Sometimes a projection should remove a view in response to an event — a user is deleted, a booking is cancelled, an item is permanently archived. Returning a fresh view object would only update the row; you need a way to tell the engine "delete this view".

noddde exports a unique-symbol sentinel DeleteView for exactly this purpose. Returning it from a reducer instructs the engine to call viewStore.delete(viewId) instead of viewStore.save(viewId, ...) for that event.

import { DeleteView, defineProjection } from "@noddde/core";

const UserProjection = defineProjection<UserProjectionDef>({
  on: {
    UserCreated: {
      id: (event) => event.payload.id,
      reduce: (event) => ({ id: event.payload.id, name: event.payload.name }),
    },
    UserDeleted: {
      id: (event) => event.payload.id,
      reduce: () => DeleteView,
    },
  },
  queryHandlers: {},
});

The reducer's return type is automatically TView | typeof DeleteView | Promise<TView | typeof DeleteView> — the union is enforced at compile time, so a reducer that returns DeleteView for one event and a view for another is fully type-safe.

How the Engine Routes DeleteView

When an event arrives for a projection with auto-persistence configured:

  1. The engine extracts the view ID via on[eventName].id(event).
  2. It loads the current view (falling back to initialView if absent).
  3. It runs the reducer and awaits the return value.
  4. If the awaited value is === DeleteView, it calls viewStore.delete(viewId). Otherwise it calls viewStore.save(viewId, newView).

The check is on the awaited value, so async reducers are supported. Because TypeScript widens a unique-symbol return from an async arrow to plain symbol when the contextual type is a union, give the async arrow an explicit return type:

// Annotate the return type so TypeScript preserves `typeof DeleteView`.
reduce: async (): Promise<typeof DeleteView> => DeleteView;

Sync arrows infer correctly without the annotation (reduce: () => DeleteView).

Conditional Deletion

Because the reducer's return type is a union, a single reducer can decide between updating the view and deleting it based on event content:

const ItemProjection = defineProjection<ItemProjectionDef>({
  on: {
    ItemDeactivated: {
      id: (event) => event.payload.id,
      reduce: (event, view) =>
        event.payload.permanent
          ? DeleteView
          : { ...view, status: "inactive" as const },
    },
  },
  queryHandlers: {},
});

When event.payload.permanent is true the engine deletes the view; otherwise it saves the updated inactive view. No special wiring is needed — the engine inspects the awaited return value at runtime.

Idempotency

Deleting a view that does not exist is a no-op. The ViewStore contract requires delete to resolve successfully on missing IDs, so returning DeleteView for an event whose view was never created is safe. This makes DeleteView reducers safe to retry and safe under at-least-once event delivery.

Strong-Consistency Deletion

For projections with consistency: "strong", view deletion is enlisted in the same unit of work as the originating command. If the command fails, the deletion is rolled back atomically:

const UserProjection = defineProjection<UserProjectionDef>({
  on: {
    UserDeleted: {
      id: (event) => event.payload.id,
      reduce: () => DeleteView,
    },
    /* ... */
  },
  queryHandlers: {},
  consistency: "strong",
});

Behaviorally it works the same as strong-consistency save — only the routing target changes (viewStore.delete instead of viewStore.save).

View Instance IDs

The id function inside each on entry tells the engine how to derive the view instance ID from an event. It is optional at the type level, but required by the engine when a view store is configured.

Each extracted event handler includes its own id function:

on-entries/on-account-created.ts
export const onAccountCreated: InferProjectionEventHandler<
  AccountProjectionDef,
  "AccountCreated"
> = {
  id: (event) => event.payload.id,
  reduce: (event) => ({ id: event.payload.id, balance: 0 }),
};
on-entries/on-deposit-made.ts
export const onDepositMade: InferProjectionEventHandler<
  AccountProjectionDef,
  "DepositMade"
> = {
  id: (event) => event.payload.accountId,
  reduce: (event, view) => ({
    ...view,
    balance: view.balance + event.payload.amount,
  }),
};

Unlike the old exhaustive identity map, only events the projection cares about need entries in on. When an event arrives, the engine:

  1. Calls on[event.name].id(event) to get the view ID
  2. Loads the current view from the view store (falling back to initialView)
  3. Runs on[event.name].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)

initialView

When load() returns undefined (new entity), the reducer receives undefined as the current view. To provide a default starting state, use initialView:

const projection = defineProjection<Def>({
  on: {
    AccountCreated: {
      id: (event) => event.payload.id,
      reduce: (event) => ({ id: event.payload.id, balance: 0 }),
    },
  },
  initialView: { id: "", balance: 0 }, // Default for new entities
  queryHandlers: {},
});

The viewStore in Domain Configuration

The viewStore value lives in the domain configuration, under projections. It must be a ViewStoreFactory — a singleton with a getForContext(ctx?) method:

projections: {
  // Class-based factory (typical for ORM-backed stores).
  Account: { viewStore: new PrismaAccountViewStoreFactory(prisma) },

  // In-memory factory (suitable for development and tests).
  Audit: { viewStore: new InMemoryViewStoreFactory<AuditView>() },
}

ViewStoreFactory — when you need transactional participation

ViewStoreFactory<TView> is a singleton with one method: getForContext(ctx?). The framework calls it in two ways:

  1. At init, with ctx === undefined. The result is cached and used for query handlers and eventual-consistency reads.
  2. Per strong-consistency read-modify-write, with ctx set to UnitOfWork.context (the active transaction handle from your adapter — a Prisma TransactionClient, a Drizzle tx, a TypeORM EntityManager). The factory mints a fresh store bound to that transaction so save, load, and any custom methods on the returned store run inside it.

Implementing one is small:

import type { Prisma, PrismaClient } from "@prisma/client";
import type { ID, ViewStore, ViewStoreFactory } from "@noddde/core";

type PrismaExec = PrismaClient | Prisma.TransactionClient;

class PrismaAccountViewStore implements AccountViewStore {
  constructor(private readonly exec: PrismaExec) {}
  async save(id: ID, view: AccountView) {
    await this.exec.account.upsert({
      where: { id: String(id) },
      update: { balance: view.balance },
      create: { id: String(id), balance: view.balance },
    });
  }
  async load(id: ID) {
    return this.exec.account.findUnique({ where: { id: String(id) } });
  }
  // Custom methods automatically use `this.exec` — transactional when minted with a tx.
  async findByBalanceRange(min: number, max: number) {
    return this.exec.account.findMany({
      where: { balance: { gte: min, lte: max } },
    });
  }
}

export class PrismaAccountViewStoreFactory
  implements ViewStoreFactory<AccountView>
{
  constructor(private readonly prisma: PrismaClient) {}
  getForContext(ctx?: unknown): AccountViewStore {
    const exec = (ctx as Prisma.TransactionClient | undefined) ?? this.prisma;
    return new PrismaAccountViewStore(exec);
  }
}

For simple cases, use the createViewStoreFactory helper:

import { createViewStoreFactory } from "@noddde/core";

const factory = createViewStoreFactory<AccountView>(
  (ctx) =>
    new PrismaAccountViewStore(
      (ctx as Prisma.TransactionClient | undefined) ?? prisma,
    ),
);

Capturing a pre-built store with createViewStoreFactory

When you already have a store instance in scope (typical in tests or lightweight setups), wrap it in a factory inline:

import { createViewStoreFactory } from "@noddde/core";

const sharedStore = new InMemoryViewStore<AccountView>();

projections: {
  Account: {
    viewStore: createViewStoreFactory(() => sharedStore),
  },
}

The builder is called with ctx?: unknown on every invocation, but for in-memory stores you can ignore it and return the same shared instance. The framework treats the result the same way it treats a class-based factory.

Consistency Modes

Projections support two consistency modes:

Eventual Consistency (default)

Views are updated asynchronously after the command's unit of work is committed and events are dispatched via the event bus.

Command -> Aggregate -> Events -> UoW commit -> Event Bus -> Projection reducer -> viewStore.save() / viewStore.delete()
                                      |                                                |
                                atomic boundary                                  independent

If the view update fails, the aggregate state is already committed. This is the standard CQRS pattern.

Strong Consistency

View updates are enlisted in the same unit of work as the originating command. Inside the enlisted thunk — i.e., inside your adapter's transactional region — the engine calls factory.getForContext(uow.context) to mint a transactionally-scoped view store, then runs load + reduce + (save or delete, depending on whether the reducer returns DeleteView) on that scoped store. If the command's UoW fails, both aggregate and view changes are rolled back atomically.

Command -> Aggregate -> Events -> [load + reduce + save/delete inside UoW commit] -> UoW commit
                                                |
                                  factory.getForContext(uow.context)
                                                |
                                       single atomic boundary

Enable it with consistency: "strong":

const projection = defineProjection<Def>({
  on: {
    Created: onCreated, // extracted handler with InferProjectionEventHandler
  },
  consistency: "strong",
  queryHandlers: {},
});

Strong-consistency projections do not subscribe to the event bus (to avoid double processing). They are processed inline during command execution.

For the atomic guarantee to hold against a real database, the factory's getForContext(ctx) must return a store that uses ctx as its transactional executor (e.g. a Prisma TransactionClient, a TypeORM EntityManager, a Drizzle tx). The framework calls getForContext(uow.context) per enlisted read-modify-write so the store sees the live transaction.

Reducers run at commit time on the current transactional snapshot, so they must be pure with respect to external state.

Validation

The engine validates projection configuration during wireDomain():

  • on entry without id when viewStore configured: Throws at init. Each on entry must have an id function when a view store is provided.
  • viewStore configured but no on entries with id: Valid only if on is empty — then the projection is query-only.
  • consistency: "strong" without viewStore in config: Silently ignored -- treated as eventual.

Optional Fields

All view persistence fields (id per on entry, initialView, consistency) are optional at the type level on the projection. The viewStore is optional in the domain configuration. However, to enable auto-persistence, provide id on each on entry and a viewStore in the domain configuration.

On this page