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:
- Command dispatched —
{ name: "AuthorizeTransaction", targetAggregateId: "...", payload: { amount: 100, merchant: "Amazon" } } - State loaded — Framework loads the aggregate's event history and replays through apply handlers
- Command handler called —
commands.AuthorizeTransaction(command, currentState, infrastructure) - Event returned — Handler decides:
TransactionAuthorizedorTransactionDeclined - Event persisted — Saved to the event store
- State updated — Apply handler called:
apply.TransactionAuthorized(event, state)returns new state - 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
- Core Concepts — Understand the foundational patterns
- Modeling Your Domain — Deep dive into aggregate definitions
- Projections — Building read models
- Running Your Domain — Complete wiring reference
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