noddde

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 voidCreateBankAccount has no payload
  • Conditional event returnAuthorizeTransaction returns either TransactionAuthorized or TransactionDeclined
  • Infrastructure injection — Logger is destructured from the third parameter
  • Immutable state transitions — All apply handlers use spread operator
  • Separate balance trackingbalance vs. availableBalance for pending transactions

On this page