View Persistence
Persisting projection views with ViewStore, auto-persistence via identity maps, 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 an identity map and a viewStore factory -- 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>;
}savepersists a view instance, replacing any previously stored view for the given ID.loadretrieves a view by ID, returningundefinedornullif not found.viewIduses the framework'sIDtype (string | number | bigint).
Extending with Custom Query Methods
The base interface provides only save and load. 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.
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 }It 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:
viewStorefield in yourProjectionTypesbundleidentitymap on the projection definitionviewStorefactory on the projection definition
import type { ViewStore, DefineEvents, DefineQueries } from "@noddde/core";
import { defineProjection } from "@noddde/core";
interface AccountView {
id: string;
balance: number;
}
interface AccountViewStore extends ViewStore<AccountView> {
findByBalanceRange(min: number, max: number): Promise<AccountView[]>;
}
type AccountEvent = DefineEvents<{
AccountCreated: { id: string; owner: string };
DepositMade: { accountId: string; amount: number };
}>;
type AccountQuery = DefineQueries<{
GetAccountById: { payload: { id: string }; result: AccountView | null };
GetAccountsInRange: {
payload: { min: number; max: number };
result: AccountView[];
};
}>;
// Infrastructure provides the view store — initialized outside the domain
type AccountInfrastructure = {
accountViewStore: AccountViewStore;
};
type AccountProjectionDef = {
events: AccountEvent;
queries: AccountQuery;
view: AccountView;
infrastructure: AccountInfrastructure;
viewStore: AccountViewStore; // Typed view store
};
const AccountProjection = defineProjection<AccountProjectionDef>({
reducers: {
AccountCreated: (event) => ({
id: event.payload.id,
balance: 0,
}),
DepositMade: (event, view) => ({
...view,
balance: view.balance + event.payload.amount,
}),
},
// Maps each event to the view instance ID
identity: {
AccountCreated: (event) => event.payload.id,
DepositMade: (event) => event.payload.accountId,
},
// Returns the already-initialized view store from infrastructure
viewStore: (infra) => infra.accountViewStore,
// Query handlers receive { views } typed as AccountViewStore
queryHandlers: {
GetAccountById: (query, { views }) => views.load(query.id),
GetAccountsInRange: (query, { views }) =>
views.findByBalanceRange(query.min, query.max),
},
});The Identity Map
The identity map tells the engine how to derive the view instance ID from each event. It must be exhaustive -- every event in the projection's event union must have a mapping.
identity: {
AccountCreated: (event) => event.payload.id,
DepositMade: (event) => event.payload.accountId,
},This mirrors the saga associations pattern. When an event arrives, the engine:
- Calls
identity[event.name](event)to get the view ID - Loads the current view from the view store
- Runs the reducer with the event and current view
- Saves the new view to the view store
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>({
reducers: { /* ... */ },
identity: { /* ... */ },
viewStore: (infra) => infra.accountViewStore,
initialView: { id: "", balance: 0 }, // Default for new entities
queryHandlers: {},
});The viewStore Factory
The viewStore field is a factory function, not a direct instance. It receives the resolved infrastructure and should return an already-initialized view store instance from it:
viewStore: (infra) => infra.accountViewStore,This keeps the projection definition free of infrastructure concerns — the view store is created and configured when you set up your infrastructure, not inside domain code. Constructing a view store inline (e.g., new InMemoryViewStore()) defeats the purpose of separating domain from infrastructure.
The factory is synchronous only. The view store must be fully initialized before being provided via infrastructure — async initialization belongs in your infrastructure setup, not in a projection definition. The factory is called during Domain.init() with the fully resolved infrastructure (custom + CQRS).
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()
| |
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. The projection reducer runs and viewStore.save() is deferred alongside aggregate persistence. If the command's UoW fails, both aggregate and view changes are rolled back atomically.
Command -> Aggregate -> Events -> [Projection reducer -> viewStore.save()] -> UoW commit
|
single atomic boundaryEnable it with consistency: "strong":
const projection = defineProjection<Def>({
reducers: { /* ... */ },
identity: { /* ... */ },
viewStore: (infra) => infra.accountViewStore,
consistency: "strong",
queryHandlers: {},
});Strong-consistency projections do not subscribe to the event bus (to avoid double processing). They are processed inline during command execution.
Validation
The engine validates projection configuration during Domain.init():
identitywithoutviewStore: Throws an error. Identity maps require a view store to persist to.viewStorewithoutidentity: Valid. Query handlers receive{ views }, but auto-persistence is not enabled (manual persistence by the user).consistency: "strong"withoutidentityandviewStore: Silently ignored -- treated as eventual.
Optional Fields
All view persistence fields (identity, viewStore, initialView, consistency) are optional at the type level. However, projections without identity and viewStore will not have their reducers subscribed to the event bus — they serve only as query handler containers. To enable auto-persistence, provide both identity and viewStore.