noddde

Defining Aggregates

How to define a complete aggregate in noddde — events, commands, state, the AggregateTypes bundle, and the defineAggregate identity function

What is an Aggregate?

An aggregate is a consistency boundary in your domain. It groups together the state and business rules that must always be consistent with each other. When you dispatch a command, a single aggregate instance validates the rules, makes a decision, and produces events that record what happened.

noddde models aggregates using the Decider pattern: commands go in, events come out, and a pure function evolves state. An aggregate is a typed object with three properties: initialState, decide, and evolve.

An aggregate definition is a blueprint, not a singleton. You define the behavior once, and the framework creates as many instances as needed at runtime, each identified by a unique targetAggregateId.

The rest of this page walks through building a complete aggregate from scratch using the banking domain as a running example.

Scaffold with the CLI.

  • New aggregate: noddde new aggregate BankAccount
  • Add a command: noddde add command AuthorizeTransaction --aggregate bank-account

The add command creates the decider and evolver and wires them into the aggregate definition. See the CLI Reference for details.

Step 1: Define Events

Events represent facts that have already occurred. Define them as a payload map using DefineEvents:

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

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

Each key becomes the event's name, and the value becomes its payload type. The resulting type is a discriminated union of all event shapes.

Naming convention: events are always past tense -- BankAccountCreated, TransactionAuthorized, BidPlaced. They describe something that already happened.

Every event has a payload. Unlike commands, there is no void payload concept for events. Even minimal events should carry the data needed to understand what occurred.

For a deeper look at how DefineEvents works under the hood, see Messages & Type System.

Step 2: Define Commands

Commands represent intent -- a request for something to happen. Define them with DefineCommands:

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

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

Each key becomes the command's name, and the value becomes its payload type. Every command in the resulting union automatically includes a targetAggregateId field that the framework uses for routing.

Naming convention: commands are always imperative -- CreateBankAccount, AuthorizeTransaction, PlaceBid. They express what the caller wants to happen.

Use void for commands that carry no data beyond the target aggregate ID. Do not use {} or undefined -- void cleanly omits the payload field from the resulting type.

For a deeper look at how DefineCommands works under the hood, see Messages & Type System.

Step 3: Define State

The state interface describes what the aggregate tracks between commands. Design it as a plain TypeScript interface:

interface BankAccountState {
  balance: number;
  availableBalance: number;
  transactions: Array<{
    id: string;
    timestamp: Date;
    amount: number;
    merchant: string;
    status: "pending" | "processed" | "declined";
  }>;
}

A few design tips:

  • Use zero values for initialState. Numbers start at 0, arrays start empty, optional references start as null. This makes the initial state trivial to construct.
  • Prefer flat structures. Deeply nested state is harder to update immutably in evolve handlers. Keep it as shallow as your domain allows.
  • The aggregate ID is NOT part of the state. It lives on the command as targetAggregateId and is managed by the framework. See Why Is the Aggregate ID Not in State? for the rationale.

Step 4: Bundle Types

Bundle the state, events, commands, and infrastructure into a single AggregateTypes type. This is the single type parameter that defineAggregate uses for full type inference across all boundaries:

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

type BankAccountDef = {
  state: BankAccountState;
  events: BankAccountEvent;
  commands: BankAccountCommand;
  infrastructure: Infrastructure;
};

The infrastructure field specifies what services your decide handlers can access. The base Infrastructure type is an empty object -- use it when your aggregate needs no external services. If your handlers need a logger, a clock, or any other service, define a custom interface instead (see Using Infrastructure below).

This bundle replaces what would otherwise be five or more positional generic parameters. For the reasoning behind this approach, see Why AggregateTypes?.

Step 5: Extract Handlers

The recommended pattern is to define decide handlers and evolve handlers as standalone functions in separate files, typed with InferDecideHandler and InferEvolveHandler. This keeps each handler focused, independently testable, and makes the aggregate definition itself a clean table of references.

Decide handlers

Each decide handler lives in its own file and is typed with InferDecideHandler<TypesBundle, "CommandName">:

// deciders/decide-create-bank-account.ts
import type { InferDecideHandler } from "@noddde/core";
import type { BankAccountDef } from "../bank-account";

export const decideCreateBankAccount: InferDecideHandler<
  BankAccountDef,
  "CreateBankAccount"
> = (command) => ({
  name: "BankAccountCreated",
  payload: { id: command.targetAggregateId },
});
// deciders/decide-authorize-transaction.ts
import type { InferDecideHandler } from "@noddde/core";
import type { BankAccountDef } from "../bank-account";

export const decideAuthorizeTransaction: InferDecideHandler<
  BankAccountDef,
  "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,
    },
  };
};

Evolve handlers

Each evolve handler lives in its own file and is typed with InferEvolveHandler<TypesBundle, "EventName">:

// evolvers/evolve-bank-account-created.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { BankAccountDef } from "../bank-account";

export const evolveBankAccountCreated: InferEvolveHandler<
  BankAccountDef,
  "BankAccountCreated"
> = (_event, _state) => ({
  balance: 0,
  availableBalance: 0,
  transactions: [],
});
// evolvers/evolve-transaction-authorized.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { BankAccountDef } from "../bank-account";

export const evolveTransactionAuthorized: InferEvolveHandler<
  BankAccountDef,
  "TransactionAuthorized"
> = (event, state) => ({
  ...state,
  availableBalance: state.availableBalance - event.amount,
  transactions: [
    ...state.transactions,
    { ...event, status: "pending" as const },
  ],
});

Step 6: defineAggregate

defineAggregate is an identity function -- it takes your aggregate object and returns it unchanged. Its only purpose is to provide type inference so TypeScript can verify that your decide handlers, evolve handlers, and initial state are all consistent.

With extracted handlers, the aggregate definition becomes a concise wiring file that imports and maps each handler:

// bank-account.ts
import { defineAggregate } from "@noddde/core";
import { decideCreateBankAccount } from "./deciders/decide-create-bank-account";
import { decideAuthorizeTransaction } from "./deciders/decide-authorize-transaction";
import { evolveBankAccountCreated } from "./evolvers/evolve-bank-account-created";
import { evolveTransactionAuthorized } from "./evolvers/evolve-transaction-authorized";
import { evolveTransactionDeclined } from "./evolvers/evolve-transaction-declined";
import { evolveTransactionProcessed } from "./evolvers/evolve-transaction-processed";

export const BankAccount = defineAggregate<BankAccountDef>({
  initialState: {
    balance: 0,
    availableBalance: 0,
    transactions: [],
  },

  decide: {
    CreateBankAccount: decideCreateBankAccount,
    AuthorizeTransaction: decideAuthorizeTransaction,
  },

  evolve: {
    BankAccountCreated: evolveBankAccountCreated,
    TransactionAuthorized: evolveTransactionAuthorized,
    TransactionDeclined: evolveTransactionDeclined,
    TransactionProcessed: evolveTransactionProcessed,
  },
});

TypeScript enforces the following at compile time:

  • Every command name in the union has a matching handler in the decide map.
  • Every event name in the union has a matching handler in the evolve map.
  • Decide handlers can only return events from the aggregate's event union.
  • Evolve handlers must return the exact state shape.
  • Infrastructure access in decide handlers is correctly typed.

Because defineAggregate is an identity function, it adds zero runtime overhead. It exists purely for the type checker.

For simple aggregates with only one or two handlers, you can define them inline directly in the defineAggregate call. But as aggregates grow, extracted handlers keep the codebase manageable.

Extracting handlers is the recommended pattern in noddde for testability, readability, type safety, and maintainability. See Why Extract Handlers? for the full rationale.

Using Infrastructure

When decide handlers need external services -- a clock, a logger, a fraud-check client -- define a custom infrastructure type and include it in the types bundle.

Here is the auction domain, which uses an injected clock to validate bid timing. The types are defined first, then the PlaceBid handler is extracted to its own file with InferDecideHandler:

// types.ts
import { Infrastructure, DefineEvents, DefineCommands } from "@noddde/core";

interface AuctionInfrastructure extends Infrastructure {
  clock: { now(): Date };
}

type AuctionEvent = DefineEvents<{
  AuctionCreated: { item: string; startingPrice: number };
  BidPlaced: { bidderId: string; amount: number; timestamp: Date };
  BidRejected: { bidderId: string; amount: number; reason: string };
  AuctionClosed: { winnerId: string | null };
}>;

type AuctionCommand = DefineCommands<{
  CreateAuction: { item: string; startingPrice: number; endsAt: Date };
  PlaceBid: { bidderId: string; amount: number };
  CloseAuction: void;
}>;

interface AuctionState {
  item: string;
  startingPrice: number;
  endsAt: Date | null;
  status: "pending" | "open" | "closed";
  highestBid: { bidderId: string; amount: number } | null;
}

export type AuctionDef = {
  state: AuctionState;
  events: AuctionEvent;
  commands: AuctionCommand;
  infrastructure: AuctionInfrastructure;
};

The PlaceBid handler destructures { clock } from the infrastructure parameter. InferDecideHandler ensures TypeScript knows exactly which services are available:

// deciders/decide-place-bid.ts
import type { InferDecideHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";

export const decidePlaceBid: InferDecideHandler<AuctionDef, "PlaceBid"> = (
  command,
  state,
  { clock },
) => {
  const { bidderId, amount } = command.payload;
  const now = clock.now();

  if (state.status !== "open") {
    return {
      name: "BidRejected",
      payload: { bidderId, amount, reason: "Auction is not open" },
    };
  }

  if (state.endsAt && now > state.endsAt) {
    return {
      name: "BidRejected",
      payload: { bidderId, amount, reason: "Auction has ended" },
    };
  }

  const minimum = state.highestBid?.amount ?? state.startingPrice;
  if (amount <= minimum) {
    return {
      name: "BidRejected",
      payload: { bidderId, amount, reason: `Bid must exceed ${minimum}` },
    };
  }

  return {
    name: "BidPlaced",
    payload: { bidderId, amount, timestamp: now },
  };
};

The aggregate definition then wires everything together:

// auction.ts
import { defineAggregate } from "@noddde/core";
import { decideCreateAuction } from "./deciders/decide-create-auction";
import { decidePlaceBid } from "./deciders/decide-place-bid";
import { decideCloseAuction } from "./deciders/decide-close-auction";
import { evolveAuctionCreated } from "./evolvers/evolve-auction-created";
import { evolveBidPlaced } from "./evolvers/evolve-bid-placed";
import { evolveBidRejected } from "./evolvers/evolve-bid-rejected";
import { evolveAuctionClosed } from "./evolvers/evolve-auction-closed";

export const Auction = defineAggregate<AuctionDef>({
  initialState: {
    item: "",
    startingPrice: 0,
    endsAt: null,
    status: "pending",
    highestBid: null,
  },

  decide: {
    CreateAuction: decideCreateAuction,
    PlaceBid: decidePlaceBid,
    CloseAuction: decideCloseAuction,
  },

  evolve: {
    AuctionCreated: evolveAuctionCreated,
    BidPlaced: evolveBidPlaced,
    BidRejected: evolveBidRejected,
    AuctionClosed: evolveAuctionClosed,
  },
});

Infrastructure is never passed to evolve handlers. Evolve handlers are pure reducers -- they receive only the event payload and the current state. This keeps state evolution deterministic and safe for event replay.

Commands vs Events at a Glance

AspectCommandEvent
TenseImperative ("do this")Past tense ("this happened")
PayloadOptional (use void for no data)Always present
targetAggregateIdPresent on aggregate commandsNot present
MeaningIntent -- may be accepted or rejectedFact -- has already occurred
MutabilityCan be retried or rejectedImmutable once persisted
Defined withDefineCommandsDefineEvents

A single command may produce different events depending on business logic. For example, AuthorizeTransaction produces either TransactionAuthorized or TransactionDeclined based on the available balance.

Next Steps

  • Decide Handlers -- Patterns for decide handler logic: async handlers, returning multiple events, validation strategies
  • State and Events -- Evolve handler patterns, immutable state updates, and modeling rejection events
  • Routing and Dispatch -- How targetAggregateId routes commands to the right aggregate instance

On this page