Aggregates, Commands, and Domain Event Modeling
Design aggregates that validate commands and emit domain events while enforcing consistency boundaries.
Aggregates, Commands, and Domain Event Modeling is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Aggregates Exist
In Event Sourcing, an aggregate is the unit that owns business rules and the source of truth for state changes. It is not a database table — it is a cluster of objects treated as one for the purpose of consistency.
- An aggregate has a single root entity that the outside world talks to.
- Every change goes through the root, so invariants can be enforced in one place.
- The aggregate is the consistency boundary: everything inside is kept transactionally valid together.
In CQRS terms, the aggregate lives on the write side. It accepts commands, validates them, and emits domain events that describe what happened.
Commands vs Events
Two message types drive an event-sourced system, and confusing them is the most common modeling mistake.
- A command is an intent: a request to do something that may be rejected. Named imperatively:
OpenAccount,WithdrawFunds. - A domain event is a fact: something that already happened and cannot be rejected. Named in past tense:
AccountOpened,FundsWithdrawn.
The aggregate is the bridge: command in → validate → events out. Events are the only thing that gets persisted; state is derived from them.
// Commands express intent (may be rejected)
const openAccount = { type: 'OpenAccount', accountId: 'a-1', owner: 'Ada' };
const withdraw = { type: 'WithdrawFunds', accountId: 'a-1', amount: 50 };
// Events express facts (already happened)
const accountOpened = { type: 'AccountOpened', accountId: 'a-1', owner: 'Ada' };
const fundsWithdrawn = { type: 'FundsWithdrawn', accountId: 'a-1', amount: 50 };
console.log('command:', withdraw.type);
console.log('event:', fundsWithdrawn.type);Rebuilding State From Events
An event-sourced aggregate never stores its current state directly. Instead it folds over its past events to reconstruct state on load. This pure function is often called apply or the reducer.
apply(state, event)must be deterministic and side-effect free.- Replaying the same event stream always yields the same state.
- This is how you load an aggregate before handling a new command.
function apply(state, event) {
switch (event.type) {
case 'AccountOpened':
return { id: event.accountId, owner: event.owner, balance: 0 };
case 'FundsDeposited':
return { ...state, balance: state.balance + event.amount };
case 'FundsWithdrawn':
return { ...state, balance: state.balance - event.amount };
default:
return state;
}
}
const history = [
{ type: 'AccountOpened', accountId: 'a-1', owner: 'Ada' },
{ type: 'FundsDeposited', amount: 100 },
{ type: 'FundsWithdrawn', amount: 30 },
];
const state = history.reduce(apply, null);
console.log(state); // { id: 'a-1', owner: 'Ada', balance: 70 }The Command Handler Shape
Command handling has a consistent shape on the aggregate:
- Decide: a pure function
(state, command) => events[]that validates invariants and returns the events to emit (or throws/returns an error). - Evolve: the
applyfunction from the previous scene, which folds events into state.
Keeping decide pure means no I/O, no clock, no randomness inside it — pass those in. This makes the core domain logic trivially unit-testable.
function decide(state, command) {
switch (command.type) {
case 'OpenAccount':
if (state) throw new Error('Account already exists');
return [{ type: 'AccountOpened', accountId: command.accountId, owner: command.owner }];
case 'WithdrawFunds':
if (!state) throw new Error('Account not found');
if (command.amount <= 0) throw new Error('Amount must be positive');
if (command.amount > state.balance) throw new Error('Insufficient funds');
return [{ type: 'FundsWithdrawn', accountId: state.id, amount: command.amount }];
default:
throw new Error('Unknown command: ' + command.type);
}
}
console.log(decide({ id: 'a-1', balance: 70 }, { type: 'WithdrawFunds', amount: 30 }));Enforcing Invariants Inside the Boundary
An invariant is a rule that must always hold true for the aggregate. The classic example: "an account balance may never go negative."
- Invariants are checked in
decidebefore any event is emitted. - If a command would break an invariant, no event is produced and the command is rejected.
- Because all state lives inside one consistency boundary, the check can be made against fully consistent data — no cross-aggregate reads needed.
This is the heart of why aggregate boundaries matter: they define exactly which data must be strongly consistent together.
Designing the Consistency Boundary
How big should an aggregate be? The rule of thumb: make it as small as possible while still being able to enforce its invariants in a single transaction.
- Data that must change together transactionally belongs in the same aggregate.
- Data that can be eventually consistent belongs in separate aggregates.
- One command should modify exactly one aggregate instance per transaction.
Example: an Order and its line items share an invariant (total = sum of lines), so they are one aggregate. A Customer and their Orders do not, so they are separate aggregates linked only by ID.
A Self-Contained Aggregate Module
Putting decide and apply together gives a complete, framework-free aggregate. Notice it depends on nothing external — it just transforms data.
loadrebuilds state from history.handleloads, decides, and returns new events for the infrastructure to persist.
function apply(state, e) {
switch (e.type) {
case 'OrderCreated': return { id: e.orderId, lines: [], placed: false };
case 'LineAdded': return { ...state, lines: [...state.lines, e.line] };
case 'OrderPlaced': return { ...state, placed: true };
default: return state;
}
}
function decide(state, cmd) {
switch (cmd.type) {
case 'CreateOrder':
if (state) throw new Error('exists');
return [{ type: 'OrderCreated', orderId: cmd.orderId }];
case 'AddLine':
if (!state || state.placed) throw new Error('cannot add line');
return [{ type: 'LineAdded', line: cmd.line }];
case 'PlaceOrder':
if (!state || state.lines.length === 0) throw new Error('empty order');
return [{ type: 'OrderPlaced', orderId: state.id }];
default: throw new Error('unknown');
}
}
const load = (history) => history.reduce(apply, null);
const handle = (history, cmd) => decide(load(history), cmd);
let stream = handle([], { type: 'CreateOrder', orderId: 'o-1' });
stream = stream.concat(handle(stream, { type: 'AddLine', line: { sku: 'X', qty: 2 } }));
console.log(handle(stream, { type: 'PlaceOrder' }));Optimistic Concurrency With Versions
Two commands can race against the same aggregate. Event Sourcing solves this with an expected version check at append time.
- Each aggregate stream has a version = number of events appended so far.
- When loading, you capture the current version.
- When appending new events, you tell the store "only succeed if the stream is still at that version."
If another writer got there first, the append fails and you reload-and-retry. This enforces the consistency boundary without locking.
// In-memory event store demonstrating optimistic concurrency
class EventStore {
constructor() { this.streams = new Map(); }
load(id) { return this.streams.get(id) || []; }
append(id, expectedVersion, newEvents) {
const current = this.load(id);
if (current.length !== expectedVersion) {
throw new Error(`Concurrency conflict: expected ${expectedVersion}, got ${current.length}`);
}
this.streams.set(id, current.concat(newEvents));
}
}
const store = new EventStore();
store.append('a-1', 0, [{ type: 'AccountOpened' }]);
try {
store.append('a-1', 0, [{ type: 'FundsDeposited', amount: 10 }]); // stale version
} catch (err) {
console.log(err.message);
}
store.append('a-1', 1, [{ type: 'FundsDeposited', amount: 10 }]); // correct version
console.log('events:', store.load('a-1').length);Idempotency and Command Deduplication
Clients retry. A network blip can cause the same command to arrive twice, and you do not want two FundsWithdrawn events from one withdrawal.
- Attach a command id (idempotency key) to each command.
- The aggregate (or a dedup layer) records which command ids it has already processed.
- A duplicate command yields zero new events instead of repeating the effect.
Combined with version checks, this gives you exactly-once effect even on an at-least-once delivery channel.
function decide(state, cmd) {
// state.processed tracks handled command ids
if (state && state.processed.includes(cmd.commandId)) {
return []; // already handled -> no new events
}
if (!state) {
return [{ type: 'Opened', commandId: cmd.commandId }];
}
return [{ type: 'Deposited', amount: cmd.amount, commandId: cmd.commandId }];
}
function apply(state, e) {
if (!state) return { balance: 0, processed: [e.commandId] };
return { balance: state.balance + (e.amount || 0), processed: [...state.processed, e.commandId] };
}
let history = [];
history = history.concat(decide(history.reduce(apply, null), { type: 'Open', commandId: 'c1' }));
const retry = decide(history.reduce(apply, null), { type: 'Deposit', amount: 5, commandId: 'c1' });
console.log('duplicate command emitted events:', retry.length); // 0Modeling Events for the Long Term
Events are stored forever, so their shape is a long-lived contract. Model them carefully.
- Name events as business facts in past tense, not CRUD verbs (
OrderShipped, notOrderUpdated). - Capture intent and meaning, not just the resulting state diff — "why" matters for projections later.
- Include a
version/schema marker so you can upcast old events when the shape evolves. - Keep events lean: only domain data, never transient infrastructure details.
// A well-modeled domain event with metadata + schema version
function priceReduced({ productId, oldPrice, newPrice, reason }) {
return {
type: 'ProductPriceReduced',
schemaVersion: 1,
occurredAt: '2026-06-10T10:00:00Z', // injected, not Date.now()
data: { productId, oldPrice, newPrice, reason },
};
}
console.log(priceReduced({
productId: 'p-9', oldPrice: 100, newPrice: 80, reason: 'clearance',
}));Wiring the Aggregate Into a Handler
Infrastructure orchestrates the pure core. A typical write-side flow in a Node.js backend:
- Load the event stream for the target aggregate id.
- Fold it with
applyto get current state and capture the version. - Run
decide(state, command)to get new events. - Append events with the expected version (optimistic concurrency).
- Publish the events so projections and other bounded contexts react.
The domain logic stays pure; only this thin handler touches the store. This separation is what makes CQRS aggregates testable and resilient.
async function handleCommand(store, bus, id, command) {
const history = await store.load(id);
const state = history.reduce(apply, null);
const version = history.length;
const newEvents = decide(state, command); // pure domain decision
if (newEvents.length === 0) return state; // idempotent no-op
await store.append(id, version, newEvents); // optimistic concurrency
for (const e of newEvents) await bus.publish(e);
return newEvents.reduce(apply, state);
}Quick Check
Test your understanding of aggregate design and consistency boundaries.
Recap
You learned how to model the write side of an event-sourced, CQRS system:
- Aggregates are consistency boundaries with a single root that owns business rules.
- Commands express intent and may be rejected; events are immutable past-tense facts and are the only persisted state.
- Core logic splits into a pure
decide(state, command) => eventsand a pureapply(state, event) => state; state is rebuilt by folding events. - Invariants are checked in
decidebefore emitting events, all within one boundary. - Size aggregates to be as small as possible while still enforcing their invariants in one transaction — one command touches one aggregate.
- Optimistic concurrency (expected version) and idempotency keys give safe, exactly-once writes without locks.
- Model events as durable, well-named, versioned contracts for the long term.
Frequently asked questions
Is the “Aggregates, Commands, and Domain Event Modeling” lesson free?
Yes — the full text of “Aggregates, Commands, and Domain Event Modeling” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Aggregates, Commands, and Domain Event Modeling”?
Design aggregates that validate commands and emit domain events while enforcing consistency boundaries. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Node.js Backend Development Bootcamp?
No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Aggregates, Commands, and Domain Event Modeling” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Node.js Backend Development Bootcamp lesson?
Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Events as the Source of Truth and the Append-Only Log
- Aggregates, Commands, and Domain Event Modeling
- Building Read Models and Projections
- Snapshots, Versioning, and Event Schema Evolution