Example: Banking Domain
Complete walkthrough of a bank account aggregate with transactions, projections, and queries.
This example walks through the complete banking domain sample included with noddde. It demonstrates commands, events, aggregates, projections, queries, infrastructure, and domain configuration working together.
Full source: samples/sample-banking — clone and run locally with
npm start.
Domain Overview
The banking domain models a simple bank account that supports:
- Account creation — Creating a new bank account
- Transaction authorization — Authorizing debits/credits against the account
- Balance tracking — Maintaining both actual balance and available balance (accounting for pending transactions)
Step 1: Define Events
Events are immutable facts about what happened. Bank account events use past tense:
import { DefineEvents } from "@noddde/core";
export type BankAccountEvent = DefineEvents<{
BankAccountCreated: { id: string };
TransactionAuthorized: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
TransactionDeclined: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
TransactionProcessed: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
}>;Four events cover the complete transaction lifecycle: creation, authorization (success/failure), and processing.
Step 2: Define Commands
Commands express intent. They use imperative verbs:
import { DefineCommands } from "@noddde/core";
export type BankAccountCommand = DefineCommands<{
CreateBankAccount: void;
AuthorizeTransaction: { amount: number; merchant: string };
}>;CreateBankAccount has no payload (void) — the account ID comes from targetAggregateId. AuthorizeTransaction carries the transaction details.
Step 3: Define State
The aggregate state tracks everything needed for business decisions:
export interface BankAccountState {
balance: number;
availableBalance: number;
transactions: Array<{
id: string;
timestamp: Date;
amount: number;
merchant: string;
status: "pending" | "processed" | "declined";
}>;
}balance is the confirmed balance. availableBalance accounts for pending transactions — it is reduced when a transaction is authorized but not yet processed.
Step 4: Define Infrastructure
The banking domain needs a logger and view repositories:
export interface BankingInfrastructure {
logger: Logger;
bankAccountViewRepository: BankAccountViewRepository;
transactionViewRepository: TransactionViewRepository;
}Step 5: Define the Aggregate
Bundle the types and define the aggregate using the Decider pattern:
import { defineAggregate } from "@noddde/core";
type BankAccountDef = {
state: BankAccountState;
events: BankAccountEvent;
commands: BankAccountCommand;
infrastructure: BankingInfrastructure;
};
export const BankAccount = defineAggregate<BankAccountDef>({
initialState: {
balance: 0,
availableBalance: 0,
transactions: [],
},
commands: {
CreateBankAccount: (command, _state, { logger }) => {
logger.info(`Creating bank account ${command.targetAggregateId}`);
return {
name: "BankAccountCreated",
payload: { id: command.targetAggregateId },
};
},
AuthorizeTransaction: (command, state, { logger }) => {
const { amount, merchant } = command.payload;
if (state.availableBalance < amount) {
logger.warn(`Transaction declined: insufficient funds`);
return {
name: "TransactionDeclined",
payload: {
id: command.targetAggregateId,
timestamp: new Date(),
amount,
merchant,
},
};
}
logger.info(`Transaction authorized: ${amount} at ${merchant}`);
return {
name: "TransactionAuthorized",
payload: {
id: command.targetAggregateId,
timestamp: new Date(),
amount,
merchant,
},
};
},
},
apply: {
BankAccountCreated: (_event, _state) => ({
balance: 0,
availableBalance: 0,
transactions: [],
}),
TransactionAuthorized: (event, state) => ({
...state,
availableBalance: state.availableBalance - event.amount,
transactions: [
...state.transactions,
{ ...event, status: "pending" as const },
],
}),
TransactionDeclined: (event, state) => ({
...state,
transactions: [
...state.transactions,
{ ...event, status: "declined" as const },
],
}),
TransactionProcessed: (event, state) => ({
...state,
balance: state.balance - event.amount,
transactions: state.transactions.map((t) =>
t.id === event.id ? { ...t, status: "processed" as const } : t,
),
}),
},
});Step 6: Define Projections
A ProjectionV2 builds a query-optimized view:
import { ProjectionV2 } from "@noddde/core";
type BankAccountView = {
id: string;
balance: number;
transactions: Array<{
id: string;
timestamp: Date;
amount: number;
status: string;
}>;
};
export const BankAccountV2: ProjectionV2<BankAccountEvent, BankAccountView> = {
reducer: (view, event) => {
switch (event.name) {
case "BankAccountCreated":
return { id: event.payload.id, balance: 0, transactions: [] };
case "TransactionProcessed":
return {
...view,
balance: view.balance + event.payload.amount,
transactions: [
...view.transactions,
{
id: event.payload.id,
timestamp: event.payload.timestamp,
amount: event.payload.amount,
status: "processed",
},
],
};
case "TransactionDeclined":
case "TransactionAuthorized":
return view;
}
},
};Step 7: Configure and Run
import {
configureDomain,
InMemoryEventSourcedAggregatePersistence,
InMemoryCommandBus,
EventEmitterEventBus,
InMemoryQueryBus,
} from "@noddde/engine";
import { randomUUID } from "crypto";
const domain = await configureDomain<BankingInfrastructure>({
writeModel: { aggregates: { BankAccount } },
readModel: { projections: { BankAccount: BankAccountProjection } },
infrastructure: {
aggregatePersistence: () => new InMemoryEventSourcedAggregatePersistence(),
provideInfrastructure: () => ({
logger: new ConsoleLogger(),
bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
transactionViewRepository: new InMemoryTransactionViewRepository(),
}),
cqrsInfrastructure: () => ({
commandBus: new InMemoryCommandBus(),
eventBus: new EventEmitterEventBus(),
queryBus: new InMemoryQueryBus(),
}),
},
});
const accountId = randomUUID();
await domain.dispatchCommand({
name: "CreateBankAccount",
targetAggregateId: accountId,
});
await domain.dispatchCommand({
name: "AuthorizeTransaction",
targetAggregateId: accountId,
payload: { amount: 100, merchant: "Internal transfer" },
});
await domain.dispatchCommand({
name: "AuthorizeTransaction",
targetAggregateId: accountId,
payload: { amount: -50, merchant: "Amazon" },
});Key Patterns Demonstrated
- DefineCommands with
void—CreateBankAccounthas no payload - Conditional event return —
AuthorizeTransactionreturns eitherTransactionAuthorizedorTransactionDeclined - Infrastructure injection — Logger is destructured from the third parameter
- Immutable state transitions — All apply handlers use spread operator
- Separate balance tracking —
balancevs.availableBalancefor pending transactions