noddde

Example: Fund Transfer

Atomic multi-command operations using domain.withUnitOfWork() to transfer funds between two accounts.

This example walks through the fund transfer sample included with noddde. It demonstrates the Unit of Work pattern for grouping multiple commands into a single atomic boundary — both succeed or neither takes effect.

Full source: samples/sample-transfers — clone and run locally with npm start.

Domain Overview

The fund transfer domain models a simple account that supports:

  • Account opening — Creating a new account with an owner
  • Deposits — Adding funds to an account
  • Withdrawals — Removing funds with a balance check
  • Atomic transfers — Moving funds between two accounts using domain.withUnitOfWork()

The key insight is that a fund transfer is not a single command — it requires a withdrawal from one account and a deposit into another. Without a unit of work, the withdrawal could persist while the deposit fails, leaving the system in an inconsistent state.

Step 1: Define Events and Commands

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

export type AccountEvent = DefineEvents<{
  AccountOpened: { owner: string };
  FundsDeposited: { amount: number };
  FundsWithdrawn: { amount: number };
}>;

export type AccountCommand = DefineCommands<{
  OpenAccount: { owner: string };
  Deposit: { amount: number };
  Withdraw: { amount: number };
}>;

Step 2: Define the Aggregate

The Withdraw command handler enforces a balance invariant — it throws when funds are insufficient:

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

export const Account = defineAggregate<AccountDef>({
  initialState: { owner: null, balance: 0 },

  commands: {
    OpenAccount: (command) => ({
      name: "AccountOpened",
      payload: { owner: command.payload.owner },
    }),

    Deposit: (command) => ({
      name: "FundsDeposited",
      payload: { amount: command.payload.amount },
    }),

    Withdraw: (command, state) => {
      if (state.balance < command.payload.amount) {
        throw new Error(
          `Insufficient funds: balance is ${state.balance}, ` +
            `attempted to withdraw ${command.payload.amount}`,
        );
      }
      return {
        name: "FundsWithdrawn",
        payload: { amount: command.payload.amount },
      };
    },
  },

  apply: {
    AccountOpened: (payload, state) => ({ ...state, owner: payload.owner }),
    FundsDeposited: (payload, state) => ({
      ...state,
      balance: state.balance + payload.amount,
    }),
    FundsWithdrawn: (payload, state) => ({
      ...state,
      balance: state.balance - payload.amount,
    }),
  },
});

Step 3: Atomic Transfer with Unit of Work

The transfer wraps both commands in a single unit of work. If the withdrawal throws (insufficient funds), the deposit never executes, and nothing persists:

await domain.withUnitOfWork(async () => {
  await domain.dispatchCommand({
    name: "Withdraw",
    targetAggregateId: "alice",
    payload: { amount: 50 },
  });
  await domain.dispatchCommand({
    name: "Deposit",
    targetAggregateId: "bob",
    payload: { amount: 50 },
  });
});

Both commands share a single unit of work. The domain buffers all persistence operations and defers all events until the callback completes. Only then does it commit everything atomically and publish the events together.

Step 4: Handling Failures

When a transfer fails, the unit of work guarantees neither account is modified:

try {
  await domain.withUnitOfWork(async () => {
    await domain.dispatchCommand({
      name: "Withdraw",
      targetAggregateId: "alice",
      payload: { amount: 300 }, // Alice only has 150
    });
    // This line is never reached
    await domain.dispatchCommand({
      name: "Deposit",
      targetAggregateId: "bob",
      payload: { amount: 300 },
    });
  });
} catch (error) {
  // Neither account was modified
  // No events were published
}

The Withdraw command handler throws because of insufficient funds. The unit of work catches this, rolls back all buffered operations, and discards all deferred events. Both accounts remain untouched.

What Happens Under the Hood

  1. withUnitOfWork() creates a new UnitOfWork and sets it as the active unit of work for the domain
  2. Each dispatchCommand inside the callback enlists its persistence operation (saving events or state) into the unit of work instead of executing immediately
  3. Events produced by each command are accumulated via deferPublish, not sent to the event bus
  4. When the callback completes successfully, the unit of work commit() executes all enlisted operations sequentially and returns the deferred events
  5. The domain then publishes all events to the event bus in order
  6. If the callback throws at any point, rollback() is called — all operations and events are discarded

With a database-backed ORM adapter, the commit() wraps operations in a real database transaction (BEGIN/COMMIT/ROLLBACK), giving you true ACID guarantees.

Key Patterns Demonstrated

  • Multi-aggregate atomicity — Two different aggregate instances modified in a single atomic boundary
  • Deferred persistence — Operations are buffered, not executed immediately
  • Deferred event publishing — Events reach projections and sagas only after all writes succeed
  • Error rollback — A failure in any command discards all buffered work
  • Same aggregate API — The aggregate definition does not know about units of work; atomicity is a configuration concern

Running the Sample

cd samples/sample-transfers
yarn run

Next Steps

On this page