noddde

Quick Start

Install noddde and build your first aggregate — a bank account with commands, events, and domain configuration

Installation

npm install @noddde/core @noddde/engine

@noddde/core provides the type definitions, identity functions, and interfaces for defining your domain. @noddde/engine provides the runtime — configureDomain, in-memory buses, and persistence.

Requirements: Node.js >= 18, TypeScript >= 5.3 with strict mode enabled:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16"
  }
}

For production persistence with a real database, see ORM Adapters.

What We Will Build

A bank account that supports:

  • Creating a new account
  • Authorizing transactions (with balance validation)
  • Two possible outcomes: authorized or declined

Step 1: Define Events

Events are immutable facts about what happened. Use DefineEvents to declare them:

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;
  };
}>;

Each key becomes the event's name discriminant. The value becomes the payload type. DefineEvents builds a discriminated union from this map.

Step 2: Define Commands

Commands express intent. Use DefineCommands:

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

export type BankAccountCommand = DefineCommands<{
  CreateBankAccount: void;
  AuthorizeTransaction: { amount: number; merchant: string };
}>;

void means CreateBankAccount has no payload — just a name and a targetAggregateId.

Step 3: Define State

The aggregate state holds 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";
  }>;
}

Note: the aggregate ID is not part of state. It lives on the command as targetAggregateId. See Why ID Not in State.

Step 4: Bundle Types

Create an AggregateTypes bundle that ties everything together:

type BankAccountDef = {
  state: BankAccountState;
  events: BankAccountEvent;
  commands: BankAccountCommand;
  infrastructure: {}; // No custom infrastructure for this example
};

This single type replaces five positional generic parameters. Learn more in Why AggregateTypes Bundle.

Step 5: Define the Aggregate

Use defineAggregate to create the aggregate definition:

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

export const BankAccount = defineAggregate<BankAccountDef>({
  // The state before any events
  initialState: {
    balance: 0,
    availableBalance: 0,
    transactions: [],
  },

  // Command handlers — decide what events to produce
  commands: {
    CreateBankAccount: (command) => ({
      name: "BankAccountCreated",
      payload: { id: command.targetAggregateId },
    }),

    AuthorizeTransaction: (command, state) => {
      const { amount, merchant } = command.payload;

      if (state.availableBalance < amount) {
        return {
          name: "TransactionDeclined",
          payload: {
            id: command.targetAggregateId,
            timestamp: new Date(),
            amount,
            merchant,
          },
        };
      }

      return {
        name: "TransactionAuthorized",
        payload: {
          id: command.targetAggregateId,
          timestamp: new Date(),
          amount,
          merchant,
        },
      };
    },
  },

  // Apply handlers — evolve state from events (pure functions)
  apply: {
    BankAccountCreated: () => ({
      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 },
      ],
    }),
  },
});

Step 6: Configure the Domain

Wire the aggregate into a running domain with in-memory infrastructure:

import {
  configureDomain,
  InMemoryCommandBus,
  EventEmitterEventBus,
  InMemoryQueryBus,
  InMemoryEventSourcedAggregatePersistence,
} from "@noddde/engine";

const domain = await configureDomain({
  writeModel: {
    aggregates: { BankAccount },
  },
  readModel: {
    projections: {},
  },
  infrastructure: {
    aggregatePersistence: () => new InMemoryEventSourcedAggregatePersistence(),
    provideInfrastructure: () => ({}),
    cqrsInfrastructure: () => ({
      commandBus: new InMemoryCommandBus(),
      eventBus: new EventEmitterEventBus(),
      queryBus: new InMemoryQueryBus(),
    }),
  },
});

Step 7: Dispatch Commands

import { randomUUID } from "crypto";

const accountId = randomUUID();

// Create the bank account
await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: accountId,
});

// Authorize a transaction
await domain.dispatchCommand({
  name: "AuthorizeTransaction",
  targetAggregateId: accountId,
  payload: { amount: 100, merchant: "Amazon" },
});

What Just Happened?

The full flow for each dispatchCommand call:

  1. Command dispatched{ name: "AuthorizeTransaction", targetAggregateId: "...", payload: { amount: 100, merchant: "Amazon" } }
  2. State loaded — Framework loads the aggregate's event history and replays through apply handlers
  3. Command handler calledcommands.AuthorizeTransaction(command, currentState, infrastructure)
  4. Event returned — Handler decides: TransactionAuthorized or TransactionDeclined
  5. Event persisted — Saved to the event store
  6. State updated — Apply handler called: apply.TransactionAuthorized(event, state) returns new state
  7. Event published — Sent to EventBus for projections to consume

This example uses optimistic concurrency with no retries (the default). For high-contention aggregates, you can configure automatic retries or pessimistic locking — see Concurrency Strategies.

Next Steps

Ready to explore a full working example? The fund transfer sample is the simplest end-to-end domain — 5 files, in-memory only, no database setup. Clone the repo and run it:

git clone https://github.com/dogganidhal/noddde.git
cd noddde && yarn install
cd samples/sample-transfers && yarn start

On this page