Quick Start
A complete noddde program in one file — copy, run, then read the walkthrough
This page shows a complete noddde program first, then unpacks it. If you only have five minutes, copy the file, run it, and come back later for the walkthrough.
Install
Use the CLI to scaffold a complete project:
npx @noddde/cli new project my-appOr install the packages manually:
npm install @noddde/core @noddde/engine@noddde/core provides types and identity functions (zero deps). @noddde/engine provides the runtime — defineDomain, wireDomain, in-memory buses and persistence.
Requirements: Node.js >= 18, TypeScript >= 5.3 with strict mode:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16"
}
}The complete program
A bank account that supports creating an account and authorizing transactions, with two outcomes: authorized or declined. Copy this into account.ts:
import { defineAggregate, DefineCommands, DefineEvents } from "@noddde/core";
import { defineDomain, wireDomain } from "@noddde/engine";
import { randomUUID } from "crypto";
// 1. Events — past-tense facts
type BankAccountEvent = DefineEvents<{
BankAccountCreated: { id: string };
TransactionAuthorized: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
TransactionDeclined: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
}>;
// 2. Commands — imperatives
type BankAccountCommand = DefineCommands<{
CreateBankAccount: void;
AuthorizeTransaction: { amount: number; merchant: string };
}>;
// 3. State — what decide handlers read from
interface BankAccountState {
balance: number;
availableBalance: number;
transactions: Array<{
id: string;
timestamp: Date;
amount: number;
merchant: string;
status: "pending" | "processed" | "declined";
}>;
}
// 4. The Def bundle — drives all type inference
type BankAccountDef = {
state: BankAccountState;
events: BankAccountEvent;
commands: BankAccountCommand;
infrastructure: {};
};
// 5. The aggregate — initialState + decide + evolve
const BankAccount = defineAggregate<BankAccountDef>({
initialState: { balance: 0, availableBalance: 0, transactions: [] },
decide: {
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,
},
};
},
},
evolve: {
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 },
],
}),
},
});
// 6. The domain — wire it with in-memory defaults
const banking = defineDomain({
writeModel: { aggregates: { BankAccount } },
readModel: { projections: {} },
});
const domain = await wireDomain(banking);
// 7. Dispatch commands
const accountId = randomUUID();
await domain.dispatchCommand({
name: "CreateBankAccount",
targetAggregateId: accountId,
});
await domain.dispatchCommand({
name: "AuthorizeTransaction",
targetAggregateId: accountId,
payload: { amount: 100, merchant: "Amazon" },
});Run it:
npx tsx account.tsThe first command creates the account; the second is declined (zero available balance) and recorded as a TransactionDeclined event. Change amount: 100 to amount: 0 to see it authorized instead.
That is the full shape: one Def per aggregate, decide produces events from state, evolve folds events back into state.
What just happened
For each dispatchCommand call, the framework:
- Receives the command —
{ name, targetAggregateId, payload } - Loads the aggregate — replays its event history through
evolveto rebuild current state - Calls
decide—decide.AuthorizeTransaction(command, state, infrastructure)returns one or more events - Persists the events — appended to the event store under
BankAccount:<id> - Updates state —
evolveis called once per new event to produce the next state - Publishes the events — sent to the event bus where projections and sagas consume them
This example uses optimistic concurrency with no retries. For high-contention aggregates, configure retries or pessimistic locking — see Concurrency Strategies.
Walkthrough
Now that you have seen the whole file, here is each part on its own.
1. Events — DefineEvents
Events are immutable facts. DefineEvents turns a name → payload map into a discriminated union:
type BankAccountEvent = DefineEvents<{
BankAccountCreated: { id: string };
TransactionAuthorized: {
id: string;
timestamp: Date;
amount: number;
merchant: string;
};
}>;Each key becomes the event's name discriminant; the value becomes its payload.
2. Commands — DefineCommands
Commands express intent. void means no payload — just a name and a targetAggregateId:
type BankAccountCommand = DefineCommands<{
CreateBankAccount: void;
AuthorizeTransaction: { amount: number; merchant: string };
}>;3. State
State holds everything decide reads to make decisions. The aggregate ID is not part of state — it lives on the command as targetAggregateId. See Why ID Not in State.
interface BankAccountState {
balance: number;
availableBalance: number;
transactions: Array<{
/* ... */
}>;
}4. The Def bundle
One type that ties state, events, commands, and any custom infrastructure together. This single type replaces five positional generic parameters elsewhere in the framework. See Why AggregateTypes Bundle.
type BankAccountDef = {
state: BankAccountState;
events: BankAccountEvent;
commands: BankAccountCommand;
infrastructure: {};
};5. The aggregate — defineAggregate
Three things: initialState, a decide map (command name → handler returning events), and an evolve map (event name → pure state transition).
const BankAccount = defineAggregate<BankAccountDef>({
initialState: {
/* ... */
},
decide: {
/* command handlers */
},
evolve: {
/* event reducers */
},
});decide is where business rules live — it can call infrastructure and return one or many events. evolve is pure: same event + state always produces the same next state. The two are different because event sourcing replays evolve against historical events to rebuild state on load.
6. The domain — defineDomain + wireDomain
defineDomain captures the static structure (which aggregates, projections, sagas exist). wireDomain connects it to infrastructure. With no second argument, you get in-memory defaults for everything:
const banking = defineDomain({
writeModel: { aggregates: { BankAccount } },
readModel: { projections: {} },
});
const domain = await wireDomain(banking);Swap to a real database with persistence adapters.
7. Dispatching commands
dispatchCommand is fully typed against the domain — name, targetAggregateId, and payload are inferred per command:
await domain.dispatchCommand({
name: "AuthorizeTransaction",
targetAggregateId: accountId,
payload: { amount: 100, merchant: "Amazon" },
});Next steps
- Core Concepts — the foundational patterns
- Modeling Your Domain — deep dive into aggregate definitions
- Projections — building read models
- Running Your Domain — complete wiring reference
The hotel booking sample exercises more than 90% of the framework — 3 aggregates, 3 sagas, 3 projections, Fastify HTTP, Drizzle/SQLite persistence:
git clone https://github.com/dogganidhal/noddde.git
cd noddde && yarn install
cd samples/sample-hotel-booking && yarn test