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>;
}savepersists a view instance, replacing any previously stored view for the given ID.loadretrieves a view by ID, returningundefinedornullif not found.deleteremoves a view by ID. Idempotent — deleting a non-existent view is a no-op and resolves successfully without throwing.viewIduses the framework'sIDtype (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"); // undefinedIt also includes convenience methods not on the base interface:
findAll()-- returns all stored viewsfind(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:
viewStoretype hint in yourProjectionTypesbundle (for typed{ views }injection)idfunction in eachonentry (tells the engine how to derive the view instance ID)viewStorefactory in the domain configuration (provides the actual store instance)
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
};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,
}),
};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,
}),
};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;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);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,
},
});// 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:
- The engine extracts the view ID via
on[eventName].id(event). - It loads the current view (falling back to
initialViewif absent). - It runs the reducer and awaits the return value.
- If the awaited value is
=== DeleteView, it callsviewStore.delete(viewId). Otherwise it callsviewStore.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:
export const onAccountCreated: InferProjectionEventHandler<
AccountProjectionDef,
"AccountCreated"
> = {
id: (event) => event.payload.id,
reduce: (event) => ({ id: event.payload.id, balance: 0 }),
};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:
- Calls
on[event.name].id(event)to get the view ID - Loads the current view from the view store (falling back to
initialView) - Runs
on[event.name].reduce(event, view)to produce the new view (orDeleteView) - If the awaited return value is
DeleteView, callsviewStore.delete(viewId); otherwise callsviewStore.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:
- At init, with
ctx === undefined. The result is cached and used for query handlers and eventual-consistency reads. - Per strong-consistency read-modify-write, with
ctxset toUnitOfWork.context(the active transaction handle from your adapter — a PrismaTransactionClient, a Drizzle tx, a TypeORMEntityManager). The factory mints a fresh store bound to that transaction sosave,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 independentIf 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 boundaryEnable 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():
onentry withoutidwhenviewStoreconfigured: Throws at init. Eachonentry must have anidfunction when a view store is provided.viewStoreconfigured but noonentries withid: Valid only ifonis empty — then the projection is query-only.consistency: "strong"withoutviewStorein 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.