Aggregates, commands en modellering van domeinevents
Ontwerp aggregates die commands valideren en domeinevents uitsturen, terwijl ze consistentiegrenzen afdwingen.
Aggregates, commands en modellering van domeinevents is een gratis Bootcamp backendontwikkeling met Node.js-les op CoddyKit. Dit is les 2 van 4. Je kunt de volledige les hieronder gratis lezen en daarna in de browser praktisch oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is. Deze les maakt deel uit van het leertraject Bootcamp backendontwikkeling met Node.js. Je voortgang wordt gesynchroniseerd op het web en in de CoddyKit-app. De cursus Bootcamp backendontwikkeling met Node.js bevat in totaal 4 lessen.
Waarom aggregaten bestaan
In Event Sourcing is een aggregaat de eenheid die eigenaar is van bedrijfsregels en de bron van waarheid vormt voor toestandswijzigingen. Het is geen databasetabel — het is een cluster objecten dat voor consistentiedoeleinden als één geheel wordt behandeld.
- Een aggregaat heeft één hoofdentiteit waarmee de buitenwereld communiceert.
- Elke wijziging gaat via de hoofdentiteit, zodat invarianten op één plek kunnen worden afgedwongen.
- Het aggregaat is de consistentiegrens: alles daarbinnen blijft gezamenlijk transactioneel geldig.
In CQRS-termen bevindt het aggregaat zich aan de schrijfkant. Het accepteert commands, valideert deze en produceert domain events die beschrijven wat er is gebeurd.
Opdrachten versus gebeurtenissen
Twee berichttypen sturen een systeem aan dat gebeurtenissen gebruikt, en ze door elkaar halen is de meest voorkomende modelleerfout.
- Een opdracht is een intentie: een verzoek om iets te doen dat kan worden afgewezen. Benoemd in de gebiedende wijs:
OpenAccount,WithdrawFunds. - Een domeingebeurtenis is een feit: iets wat al is gebeurd en niet kan worden afgewezen. Benoemd in de verleden tijd:
AccountOpened,FundsWithdrawn.
Het aggregaat vormt de brug: command in → validate → events out. Alleen gebeurtenissen worden opgeslagen; de toestand wordt daaruit afgeleid.
// 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);Toestand opnieuw opbouwen uit gebeurtenissen
Een aggregaat dat gebeurtenissen gebruikt, slaat zijn huidige toestand nooit rechtstreeks op. In plaats daarvan vouwt het bij het laden zijn eerdere gebeurtenissen samen om de toestand te reconstrueren. Deze pure functie heet vaak apply of de reduceerfunctie.
apply(state, event)moet deterministisch zijn en vrij van neveneffecten.- Dezelfde gebeurtenisstroom opnieuw afspelen levert altijd dezelfde toestand op.
- Zo laad je een aggregaat voordat je een nieuwe opdracht verwerkt.
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 }De vorm van de opdrachtverwerker
De verwerking van opdrachten heeft binnen het aggregaat een vaste vorm:
- Beslissen: een pure functie
(state, command) => events[]die invarianten valideert en de gebeurtenissen retourneert die moeten worden geproduceerd (of een fout gooit/retourneert). - Evolueren: de functie
applyuit de vorige scène, die gebeurtenissen tot een toestand samenvoegt.
Als je decide puur houdt, bevat deze geen invoer en uitvoer, klok of willekeur — geef die van buitenaf mee. Daardoor is de kern van de domeinlogica zonder moeite met unittests te testen.
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 }));Invarianten binnen de grens afdwingen
Een invariant is een regel die altijd moet gelden voor het aggregaat. Het klassieke voorbeeld: "het saldo van een account mag nooit negatief worden."
- Invarianten worden in
decidegecontroleerd voordat er een gebeurtenis wordt geproduceerd. - Als een opdracht een invariant zou schenden, wordt er geen gebeurtenis geproduceerd en wordt de opdracht afgewezen.
- Omdat alle toestand binnen één consistentiegrens staat, kan de controle worden uitgevoerd op volledig consistente gegevens — lezen uit andere aggregaten is niet nodig.
Dit is de kern van het belang van aggregaatgrenzen: ze bepalen precies welke gegevens samen sterk consistent moeten zijn.
De consistentiegrens ontwerpen
Hoe groot moet een aggregaat zijn? De vuistregel: maak het zo klein mogelijk, zolang je de invarianten ervan maar in één transactie kunt afdwingen.
- Gegevens die transactioneel samen moeten wijzigen, horen bij hetzelfde aggregaat.
- Gegevens die uiteindelijk consistent mogen worden, horen bij afzonderlijke aggregaten.
- Eén opdracht mag per transactie precies één aggregaatinstantie wijzigen.
Voorbeeld: een Order en de bijbehorende regels delen een invariant (totaal = som van de regels), dus vormen ze één aggregaat. Een Customer en diens Orders doen dat niet, dus zijn het afzonderlijke aggregaten die alleen via ID aan elkaar zijn gekoppeld.
Een zelfstandig aggregaatmodule
Door decide en apply te combineren krijg je een compleet frameworkonafhankelijk aggregate. Het is volledig onafhankelijk van externe zaken: het transformeert alleen gegevens.
loadreconstrueert de toestand uit de geschiedenis.handlelaadt de toestand, neemt een beslissing en retourneert nieuwe gebeurtenissen die de infrastructuur kan opslaan.
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' }));Optimistische gelijktijdigheid met versies
Twee opdrachten kunnen tegelijk proberen hetzelfde aggregate te wijzigen. Event Sourcing lost dit op met een controle van de verwachte versie tijdens het toevoegen.
- Elke aggregatestroom heeft een versie: het aantal gebeurtenissen dat tot nu toe is toegevoegd.
- Bij het laden leg je de huidige versie vast.
- Bij het toevoegen van nieuwe gebeurtenissen geef je aan: "sla alleen op als de stroom nog steeds deze versie heeft".
Als een andere schrijver eerder klaar was, mislukt het toevoegen en laad je opnieuw voordat je het nogmaals probeert. Zo wordt de consistentiegrens afgedwongen zonder vergrendeling.
// 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);Idempotentie en deduplicatie van opdrachten
Clients proberen opdrachten opnieuw. Door een korte netwerkstoring kan dezelfde opdracht twee keer aankomen, terwijl je niet twee FundsWithdrawn-gebeurtenissen wilt voor één opname.
- Voeg aan elke opdracht een opdracht-id (idempotentiesleutel) toe.
- Het aggregate (of een deduplicatielaag) registreert welke opdracht-id's al zijn verwerkt.
- Een dubbele opdracht levert geen nieuwe gebeurtenissen op, in plaats van het effect opnieuw uit te voeren.
In combinatie met versiecontroles zorgt dit voor een precies-één-keer-effect, zelfs via een kanaal dat levering minstens één keer garandeert.
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); // 0Gebeurtenissen modelleren voor de lange termijn
Gebeurtenissen worden voor altijd opgeslagen, dus hun vorm is een contract voor de lange termijn. Modelleer ze zorgvuldig.
- Geef gebeurtenissen namen als zakelijke feiten in de voltooide tijd, niet als CRUD-werkwoorden (
OrderShipped, nietOrderUpdated). - Leg intentie en betekenis vast, niet alleen het verschil in de resulterende toestand: het "waarom" is later belangrijk voor projecties.
- Neem een
version-/schemamarkering op, zodat je oude gebeurtenissen kunt upcasten wanneer de vorm verandert. - Houd gebeurtenissen compact: alleen domeingegevens, nooit tijdelijke infrastructuurdetails.
// 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',
}));Het aggregate in een handler aansluiten
De infrastructuur orkestreert de zuivere kern. Een typische schrijfzijde-stroom in een Node.js-backend:
- Laad de gebeurtenisstroom voor de id van het doelaggregate.
- Vouw de stroom met
applysamen om de huidige toestand te verkrijgen en de versie vast te leggen. - Voer
decide(state, command)uit om nieuwe gebeurtenissen te verkrijgen. - Voeg de gebeurtenissen toe met de verwachte versie (optimistische gelijktijdigheid).
- Publiceer de gebeurtenissen, zodat projecties en andere begrensde contexten erop kunnen reageren.
De domeinlogica blijft zuiver; alleen deze dunne handler heeft contact met de opslag. Deze scheiding maakt CQRS-aggregates goed testbaar en veerkrachtig.
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);
}Korte controle
Toets je begrip van aggregatemodellering en consistentiegrenzen.
Samenvatting
Je hebt geleerd hoe je de schrijfzijde van een event-sourced CQRS-systeem modelleert:
- Aggregates zijn consistentiegrenzen met één hoofdobject dat de bedrijfsregels beheert.
- Opdrachten drukken intentie uit en kunnen worden afgewezen; gebeurtenissen zijn onveranderlijke feiten in de voltooide tijd en vormen de enige opgeslagen toestand.
- De kernlogica wordt opgesplitst in een zuivere
decide(state, command) => eventsen een zuivereapply(state, event) => state; de toestand wordt opnieuw opgebouwd door gebeurtenissen samen te vouwen. - Invarianten worden in
decidegecontroleerd voordat gebeurtenissen worden uitgegeven, volledig binnen één grens. - Maak aggregates zo klein mogelijk, maar groot genoeg om hun invarianten in één transactie af te dwingen: één opdracht raakt één aggregate.
- Optimistische gelijktijdigheid (verwachte versie) en idempotentiesleutels zorgen voor veilige schrijfbewerkingen die precies één keer effect hebben, zonder vergrendelingen.
- Modelleer gebeurtenissen als duurzame, duidelijk benoemde en versieerde contracten voor de lange termijn.
Leer JavaScript met een AI-tutor — gratis
Schrijf echte code en voer die uit in je browser, krijg direct hulp van een AI-tutor die 24/7 beschikbaar is en ga verder waar je gebleven bent op het web of in de app.
- Cursussen
- 22
- Lessen
- 92
Veelgestelde vragen
Is de les “Aggregates, commands en modellering van domeinevents” gratis?
Ja — de volledige tekst van “Aggregates, commands en modellering van domeinevents” kun je hier gratis op het web lezen. Als je interactief wilt oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is, en de rest van de cursus Bootcamp backendontwikkeling met Node.js wilt ontgrendelen, kun je upgraden naar CoddyKit PRO. De cursus Bootcamp backendontwikkeling met Node.js bevat in totaal 4 lessen.
Wat leer ik in “Aggregates, commands en modellering van domeinevents”?
Ontwerp aggregates die commands valideren en domeinevents uitsturen, terwijl ze consistentiegrenzen afdwingen. Je oefent met Bootcamp backendontwikkeling met Node.js door code rechtstreeks in de browser uit te voeren. Een AI-begeleider die 24/7 beschikbaar is beantwoordt je vragen terwijl je de les doorwerkt.
Heb ik ervaring nodig om met Bootcamp backendontwikkeling met Node.js te beginnen?
Ervaring vooraf is niet nodig. Bootcamp backendontwikkeling met Node.js op CoddyKit is opgebouwd voor beginners tot gevorderden, zodat je hier of bij het begin kunt starten en in je eigen tempo kunt leren. Dit is les 2 van 4.
Hoe lang duurt de les “Aggregates, commands en modellering van domeinevents”?
De meeste lessen van CoddyKit duren ongeveer 5–10 minuten. Elke les is kort en interactief, zodat je gestaag vooruitgaat en op het web en in de app precies verdergaat waar je was gebleven.
Kan ik code schrijven en uitvoeren in deze les over Bootcamp backendontwikkeling met Node.js?
Ja. Elke les over Bootcamp backendontwikkeling met Node.js bevat een ingebouwde code-editor, zodat je rechtstreeks in je browser echte code kunt schrijven en uitvoeren en direct feedback van AI krijgt — lokale installatie is niet nodig.
Alle lessen in deze cursus
- Events als bron van waarheid en het append-only-log
- Aggregates, commands en modellering van domeinevents
- Read-modellen en projections bouwen
- Snapshots, versiebeheer en evolutie van eventschema's