noddde

Concepts at a Glance

A one-screen summary of the building blocks — aggregates, projections, sagas, and how they fit together

This page is a quick reference. If you have not run the Quick Start yet, do that first — the concepts make a lot more sense once you have seen them work.

The five building blocks

Aggregate

A consistency boundary. Processes commands, enforces invariants, produces events. Defined as initialState + decide + evolve:

const BankAccount = defineAggregate<BankAccountDef>({
  initialState: { balance: 0 },
  decide: {
    AuthorizeTransaction: (command, state, infra) => {
      /* return events */
    },
  },
  evolve: {
    TransactionAuthorized: (event, state) => {
      /* return new state */
    },
  },
});

decide decides; evolve evolves state from events. Both are pure functions of their arguments. → Defining Aggregates

Command

Imperative. The thing the user is asking the aggregate to do.

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

Carries a name, a targetAggregateId, and an optional payload. → Messages & Types

Event

Past-tense fact. The thing that actually happened.

type BankAccountEvent = DefineEvents<{
  BankAccountCreated: { id: string };
  TransactionAuthorized: { id: string; amount: number; merchant: string };
}>;

Immutable. Persisted. Replayed by evolve to rebuild state on load.

Projection

The query side of CQRS. Builds read-optimized views from event streams.

const BankAccountProjection = defineProjection<BankAccountProjectionDef>({
  on: {
    TransactionAuthorized: {
      reduce: (event, view) => ({
        ...view,
        balance: view.balance + event.payload.amount,
      }),
    },
  },
  queryHandlers: {
    /* ... */
  },
});

Projections

Saga

Process manager. Listens to events, emits commands. The structural inverse of an aggregate.

const OrderFulfillmentSaga = defineSaga<OrderFulfillmentSagaDef>({
  initialState: { status: null },
  startedBy: ["OrderPlaced"],
  on: {
    OrderPlaced: {
      id: (event) => event.payload.orderId,
      handle: (event, state) => ({
        state: { ...state, status: "awaiting_payment" },
        commands: { name: "RequestPayment" /* ... */ },
      }),
    },
  },
});

Sagas

How they fit together

defineDomain captures the structure; wireDomain plugs in infrastructure:

const myDomain = defineDomain({
  writeModel: { aggregates: { BankAccount } },
  readModel: { projections: { BankAccount: BankAccountProjection } },
  processModel: { sagas: { OrderFulfillment: OrderFulfillmentSaga } },
});

const domain = await wireDomain(myDomain);

With no second argument you get in-memory defaults. Swap to real persistence with adapters.

What's next

  • Quick Start — see all of this in one runnable file
  • Core Concepts — the deeper foundations
  • Why noddde — how this compares to NestJS CQRS, hand-rolled, Effect, Wolkenkit

On this page