Agrégats, commandes et modélisation des événements métier
Concevez des agrégats qui valident les commandes et émettent des événements métier tout en respectant les limites de cohérence.
Agrégats, commandes et modélisation des événements métier est une leçon Node.js Backend Development Bootcamp gratuite sur CoddyKit. Ceci est la leçon 2 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage Node.js Backend Development Bootcamp, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours Node.js Backend Development Bootcamp comprend 4 leçons au total.
Certaines parties de cette leçon n'ont pas encore été traduites et s'affichent en anglais.
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.
Questions Fréquemment Posées
La leçon « Agrégats, commandes et modélisation des événements métier » est-elle gratuite ?
Oui — le texte complet de « Agrégats, commandes et modélisation des événements métier » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours Node.js Backend Development Bootcamp, passe à CoddyKit PRO. Le cours Node.js Backend Development Bootcamp comprend 4 leçons au total.
Qu'est-ce que j'apprendrai dans « Agrégats, commandes et modélisation des événements métier » ?
Concevez des agrégats qui valident les commandes et émettent des événements métier tout en respectant les limites de cohérence. Tu pratiques Node.js Backend Development Bootcamp avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.
Dois-je avoir de l'expérience pour commencer Node.js Backend Development Bootcamp ?
Aucune expérience préalable n'est requise. Node.js Backend Development Bootcamp sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 2 sur 4.
Combien de temps prend la leçon « Agrégats, commandes et modélisation des événements métier » ?
La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.
Peux-tu écrire et exécuter du code dans cette leçon Node.js Backend Development Bootcamp ?
Oui. Chaque leçon Node.js Backend Development Bootcamp inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.
Toutes les leçons de ce cours
- Les événements comme source de vérité et le journal en ajout uniquement
- Agrégats, commandes et modélisation des événements métier
- Créer des modèles de lecture et des projections
- Instantanés, gestion des versions et évolution du schéma d’événements