Bootcamp backendontwikkeling met Node.js · Les

Events als bron van waarheid en het append-only-log

Vervang mutable state door een onveranderlijke eventstream en reconstrueer de status door events opnieuw af te spelen.

Les 1 van 413 stappen

Events als bron van waarheid en het append-only-log is een gratis Bootcamp backendontwikkeling met Node.js-les op CoddyKit. Dit is les 1 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.

Het probleem met veranderlijke status

In een klassieke CRUD-backend sla je de huidige toestand van een entiteit op en overschrijf je die bij elke wijziging. Het saldo van een gebruiker is één rij; wanneer er geld wordt overgemaakt, UPDATE je het getal direct.

Dit is handig, maar ook verliesgevend. Zodra je de oude waarde overschrijft, is de geschiedenis verdwenen. Je kunt niet meer beantwoorden:

  • Hoe heeft het saldo deze waarde bereikt?
  • Wanneer en waarom vond elke wijziging plaats?
  • Hoe zag de toestand er afgelopen dinsdag uit?

Event Sourcing draait dit om: in plaats van de meest recente toestand op te slaan, sla je de reeks feiten op die deze toestand heeft voortgebracht.

// Classic mutable CRUD: history is destroyed on every write
let account = { id: 'acc-1', balance: 100 };

function deposit(amount) {
  account.balance += amount; // old value is gone forever
}

function withdraw(amount) {
  account.balance -= amount; // no record of why or when
}

deposit(50);
withdraw(30);
console.log(account); // { id: 'acc-1', balance: 120 }
// We know the result, but not the journey.

Gebeurtenissen als bron van waarheid

In Event Sourcing is een gebeurtenis een onveranderlijk verslag van iets wat al is gebeurd. Gebeurtenissen krijgen een naam in de verleden tijd: MoneyDeposited, MoneyWithdrawn, AccountOpened.

Het gebeurtenislogboek wordt de bron van waarheid. De huidige toestand wordt niet langer rechtstreeks opgeslagen — het is een afgeleide waarde die je berekent door gebeurtenissen opnieuw af te spelen.

  • Gebeurtenissen zijn feiten: je kunt ze niet wijzigen of verwijderen.
  • Toestand is een interpretatie: een projectie van de feiten op een bepaald moment.

Elke gebeurtenis legt intentie en context vast, niet alleen het resulterende getal.

// An event is an immutable, past-tense fact
const events = [
  { type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' }, at: '2026-01-01T09:00:00Z' },
  { type: 'MoneyDeposited', data: { accountId: 'acc-1', amount: 50 },  at: '2026-01-02T10:15:00Z' },
  { type: 'MoneyWithdrawn', data: { accountId: 'acc-1', amount: 30 },  at: '2026-01-03T11:30:00Z' }
];

// Each entry is a fact that already happened. Nothing is overwritten.
console.log(`Stored ${events.length} immutable facts.`);

Het logboek waaraan alleen wordt toegevoegd

De opslag waarin gebeurtenissen staan, is een logboek waaraan alleen wordt toegevoegd. Er zijn slechts twee echte bewerkingen:

  • Voeg een nieuwe gebeurtenis aan het einde toe.
  • Lees gebeurtenissen in volgorde, meestal voor een bepaalde stroom (bijvoorbeeld één account).

Er is geen UPDATE en geen DELETE. Deze ene beperking geeft je gratis een compleet, geordend en manipulatiebestendig controletraject.

Omdat het logboek geordend is, is de positie (of versie) van elke gebeurtenis belangrijk: gebeurtenissen in dezelfde volgorde opnieuw afspelen levert altijd dezelfde toestand op.

// A minimal in-memory append-only log
class EventLog {
  constructor() { this.events = []; }

  append(event) {
    // Only ever push to the end — never mutate or remove
    const stored = { ...event, position: this.events.length + 1 };
    this.events.push(Object.freeze(stored));
    return stored;
  }

  read() {
    return [...this.events]; // ordered copy
  }
}

const log = new EventLog();
log.append({ type: 'AccountOpened', data: { accountId: 'acc-1' } });
log.append({ type: 'MoneyDeposited', data: { accountId: 'acc-1', amount: 50 } });
console.log(log.read());

Toestand reconstrueren door opnieuw af te spelen

Als de toestand is afgeleid, hoe krijgen we die dan terug? We spelen opnieuw af: we beginnen met een lege toestand en passen elke gebeurtenis in volgorde toe. Deze functie heet vaak apply, evolve of een reduceerfunctie.

Let op de vorm: (state, event) => newState. Dit is precies een reduceerfunctie — hetzelfde idee als Array.prototype.reduce.

  • De reduceerfunctie is puur: dezelfde gebeurtenissen als invoer leveren dezelfde toestand als uitvoer.
  • De functie verwerkt elk gebeurtenistype en retourneert een nieuw toestandobject.
function applyEvent(state, event) {
  switch (event.type) {
    case 'AccountOpened':
      return { id: event.data.accountId, owner: event.data.owner, balance: 0 };
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.data.amount };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.data.amount };
    default:
      return state; // ignore unknown events
  }
}

const events = [
  { type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' } },
  { type: 'MoneyDeposited', data: { amount: 50 } },
  { type: 'MoneyWithdrawn', data: { amount: 30 } }
];

const state = events.reduce(applyEvent, null);
console.log(state); // { id: 'acc-1', owner: 'Ada', balance: 20 }

Stromen en aggregaten

Je speelt zelden elke gebeurtenis in het systeem opnieuw af. Gebeurtenissen worden gegroepeerd in stromen, één per entiteit — bijvoorbeeld account-acc-1. De entiteit die uit een stroom wordt gereconstrueerd, heet een aggregaat.

Een aggregaat laden:

  • Lees alleen de gebeurtenissen van die stroom (gefilterd op streamId).
  • Speel ze opnieuw af via de reduceerfunctie.
  • Retourneer de resulterende toestand in het geheugen.

Door stromen klein te houden, blijft het opnieuw afspelen snel en blijven de consistentiegrenzen duidelijk.

function loadAggregate(allEvents, streamId, reducer) {
  return allEvents
    .filter(e => e.streamId === streamId)
    .sort((a, b) => a.position - b.position)
    .reduce(reducer, null);
}

const allEvents = [
  { streamId: 'account-acc-1', position: 1, type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' } },
  { streamId: 'account-acc-2', position: 1, type: 'AccountOpened',  data: { accountId: 'acc-2', owner: 'Lin' } },
  { streamId: 'account-acc-1', position: 2, type: 'MoneyDeposited', data: { amount: 75 } }
];

const reducer = (s, e) => {
  if (e.type === 'AccountOpened') return { id: e.data.accountId, balance: 0 };
  if (e.type === 'MoneyDeposited') return { ...s, balance: s.balance + e.data.amount };
  return s;
};

console.log(loadAggregate(allEvents, 'account-acc-1', reducer));

Opdrachten versus gebeurtenissen

Een belangrijk onderscheid: een opdracht is een verzoek om iets te doen (dat kan worden afgewezen); een gebeurtenis is het verslag dat het is gebeurd (dat kan niet ongedaan worden gemaakt).

  • Withdraw is een opdracht — gebiedende wijs, tegenwoordige tijd, kan mislukken.
  • MoneyWithdrawn is een gebeurtenis — verleden tijd, een vaststaand feit.

De taak van het aggregaat is om de huidige toestand samen met een opdracht te nemen, de bedrijfsregels uit te voeren en te bepalen welke gebeurtenis(sen) aan het logboek moeten worden toegevoegd — of de opdracht volledig af te wijzen.

function decide(state, command) {
  switch (command.type) {
    case 'Withdraw':
      if (command.amount > state.balance) {
        throw new Error('Insufficient funds'); // command rejected
      }
      return [{ type: 'MoneyWithdrawn', data: { amount: command.amount } }];
    case 'Deposit':
      return [{ type: 'MoneyDeposited', data: { amount: command.amount } }];
    default:
      throw new Error('Unknown command: ' + command.type);
  }
}

const state = { balance: 40 };
console.log(decide(state, { type: 'Withdraw', amount: 30 }));
try { decide(state, { type: 'Withdraw', amount: 100 }); }
catch (e) { console.log('Rejected:', e.message); }

De cyclus Beslissen / Evolueren

Als je alles samenvoegt, krijg je de kern van de schrijfdoorstroming van een aggregaat dat gebeurtenissen gebruikt:

  • Laden: speel de stroom opnieuw af om de huidige toestand opnieuw op te bouwen.
  • Beslissen: voer de opdracht uit op die toestand om nieuwe gebeurtenissen te produceren.
  • Toevoegen: schrijf de nieuwe gebeurtenissen naar het logboek.
  • Evolueren: dezelfde reduceerfunctie die de toestand heeft geladen, houdt deze ook actueel.

De functie decide schrijft nooit; de reduceerfunctie evolve valideert nooit. Door deze scheiding blijft de domeinlogica overzichtelijk en goed testbaar.

function evolve(state, event) {
  if (event.type === 'MoneyDeposited') return { ...state, balance: state.balance + event.data.amount };
  if (event.type === 'MoneyWithdrawn') return { ...state, balance: state.balance - event.data.amount };
  return state;
}
function decide(state, cmd) {
  if (cmd.type === 'Deposit') return [{ type: 'MoneyDeposited', data: { amount: cmd.amount } }];
  if (cmd.type === 'Withdraw' && cmd.amount <= state.balance)
    return [{ type: 'MoneyWithdrawn', data: { amount: cmd.amount } }];
  throw new Error('Invalid command');
}

let history = [{ type: 'MoneyDeposited', data: { amount: 100 } }];
let state = history.reduce(evolve, { balance: 0 });   // load
const newEvents = decide(state, { type: 'Withdraw', amount: 60 }); // decide
history = [...history, ...newEvents];                 // append
state = newEvents.reduce(evolve, state);              // evolve
console.log(state); // { balance: 40 }

Optimistische gelijktijdigheid met de verwachte versie

Twee verzoeken kunnen tegelijkertijd proberen hetzelfde aggregaat te wijzigen. Omdat er alleen aan het logboek wordt toegevoegd, beveiligen we schrijfbewerkingen met de verwachte versie: de positie waarop de schrijver denkt dat de stroom staat.

Bij het toevoegen zeg je: "Ik verwacht dat deze stroom op versie N staat." Als een andere schrijver de stroom al heeft bijgewerkt, mislukt het toevoegen en probeert de aanroeper het opnieuw na het opnieuw laden.

  • Er worden geen vergrendelingen vastgehouden tijdens het verzoek.
  • Conflicten worden gedetecteerd en nooit stilzwijgend verloren.
class VersionedStore {
  constructor() { this.streams = new Map(); }

  append(streamId, expectedVersion, newEvents) {
    const current = this.streams.get(streamId) || [];
    if (current.length !== expectedVersion) {
      throw new Error(
        `Concurrency conflict: expected v${expectedVersion}, got v${current.length}`
      );
    }
    this.streams.set(streamId, [...current, ...newEvents]);
    return current.length + newEvents.length;
  }
}

const store = new VersionedStore();
store.append('acc-1', 0, [{ type: 'AccountOpened' }]);   // ok -> v1
try {
  store.append('acc-1', 0, [{ type: 'MoneyDeposited' }]); // stale version
} catch (e) { console.log(e.message); }
store.append('acc-1', 1, [{ type: 'MoneyDeposited' }]);  // correct -> v2
console.log('Final version:', store.streams.get('acc-1').length);

Momentopnamen: opnieuw afspelen zonder alles opnieuw te lezen

Duizenden gebeurtenissen bij elke laadbewerking opnieuw afspelen wordt langzaam. Een momentopname is een opgeslagen kopie van de toestand van het aggregaat bij een bekende versie. Bij het laden begin je met de momentopname en speel je alleen de gebeurtenissen erna opnieuw af.

Belangrijk: momentopnamen zijn een optimalisatie, geen bron van waarheid. Je kunt elke momentopname verwijderen en de perfecte toestand nog steeds uit de gebeurtenissen opbouwen. Het logboek blijft leidend.

  • Sla de momentopname op samen met de versie die deze vertegenwoordigt.
  • Bij het laden: laad de toestand uit de momentopname en speel daarna het resterende deel opnieuw af.
function loadWithSnapshot(snapshot, events, evolve) {
  // snapshot = { state, version } or null
  let state = snapshot ? snapshot.state : { balance: 0 };
  const fromVersion = snapshot ? snapshot.version : 0;
  return events
    .filter(e => e.position > fromVersion)
    .reduce(evolve, state);
}

const evolve = (s, e) =>
  e.type === 'MoneyDeposited' ? { balance: s.balance + e.data.amount } : s;

const events = [
  { position: 1, type: 'MoneyDeposited', data: { amount: 100 } },
  { position: 2, type: 'MoneyDeposited', data: { amount: 50 } },
  { position: 3, type: 'MoneyDeposited', data: { amount: 25 } }
];
const snapshot = { state: { balance: 150 }, version: 2 };
console.log(loadWithSnapshot(snapshot, events, evolve)); // { balance: 175 }

Schema-evolutie en upcasting

Gebeurtenissen blijven voor altijd bestaan, dus hun vorm blijft langer bestaan dan de code waarmee ze zijn geschreven. Je kunt een oude gebeurtenis nooit rechtstreeks aanpassen, maar de nieuwe code moet deze nog steeds begrijpen.

Het gebruikelijke hulpmiddel is upcasting: een functie die een oude gebeurtenisversie omzet naar de huidige vorm tijdens het lezen, voordat de gebeurtenis de reduceerfunctie bereikt.

  • Voeg aan elk gebeurtenistype een veld version toe.
  • Voorzie nieuwe velden van zinvolle standaardwaarden; hernoem velden via de upcaster.
  • Wijzig opgeslagen gebeurtenissen nooit — zet onderweg een kopie om.
// v1 had `amount` (cents implied); v2 adds explicit `currency`
function upcast(event) {
  if (event.type === 'MoneyDeposited' && (event.version || 1) === 1) {
    return {
      ...event,
      version: 2,
      data: { ...event.data, currency: 'USD' } // default for legacy events
    };
  }
  return event;
}

const legacy = { type: 'MoneyDeposited', data: { amount: 50 } };
console.log(upcast(legacy));
// { type: 'MoneyDeposited', data: { amount: 50, currency: 'USD' }, version: 2 }

Het logboek opslaan in Node.js

In productie wordt het logboek waaraan alleen wordt toegevoegd ondersteund door duurzame opslag: een speciale opslag zoals EventStoreDB, of een relationele tabel die als logboek wordt gebruikt. Een typisch Postgres-ontwerp:

  • Eén tabel events met (stream_id, version, type, data jsonb, recorded_at).
  • Een unieke beperking op (stream_id, version) — hiermee wordt optimistische gelijktijdigheid op databaseniveau afgedwongen.
  • Alleen invoegbewerkingen; de applicatiecode voert er nooit UPDATE of DELETE op uit.

Dezelfde cyclus van laden, beslissen en toevoegen wordt erboven uitgevoerd, alleen met SQL achter de opslaginterface.

// Sketch of an append against a Postgres-backed log (pg client `db`).
// The UNIQUE (stream_id, version) constraint rejects concurrent duplicates.
async function appendEvents(db, streamId, expectedVersion, events) {
  const client = await db.connect();
  try {
    await client.query('BEGIN');
    let version = expectedVersion;
    for (const e of events) {
      version += 1;
      await client.query(
        `INSERT INTO events (stream_id, version, type, data)
         VALUES ($1, $2, $3, $4)`,
        [streamId, version, e.type, JSON.stringify(e.data)]
      );
    }
    await client.query('COMMIT');
    return version;
  } catch (err) {
    await client.query('ROLLBACK'); // unique violation => concurrency conflict
    throw err;
  } finally {
    client.release();
  }
}

Korte controle

Bekijk een accountaggregaat dat gebeurtenissen gebruikt.

Samenvatting

Je hebt veranderlijke toestand vervangen door een onveranderlijke gebeurtenisstroom:

  • Gebeurtenissen zijn onveranderlijke feiten in de verleden tijd; het logboek waaraan alleen wordt toegevoegd waarin ze staan, is de bron van waarheid.
  • Toestand is afgeleid en wordt niet opgeslagen — je bouwt deze opnieuw op door gebeurtenissen via een pure reduceerfunctie af te spelen: (state, event) => newState.
  • Gebeurtenissen worden gegroepeerd in stromen, één per aggregaat.
  • De schrijfcyclus is laden → beslissen → toevoegen → evolueren; opdrachten kunnen worden afgewezen, gebeurtenissen nooit.
  • De verwachte versie maakt optimistische gelijktijdigheid mogelijk; een beperking UNIQUE(stream_id, version) dwingt deze in de database af.
  • Momentopnamen versnellen het opnieuw afspelen maar zijn optioneel; upcasting zorgt ervoor dat oude gebeurtenissen leesbaar blijven wanneer schema's evolueren.

Hierna bouw je leesmodellen (projecties) en maak je het CQRS-plaatje compleet.

Gratis beginnen

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 “Events als bron van waarheid en het append-only-log” gratis?

Ja — de volledige tekst van “Events als bron van waarheid en het append-only-log” 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 “Events als bron van waarheid en het append-only-log”?

Vervang mutable state door een onveranderlijke eventstream en reconstrueer de status door events opnieuw af te spelen. 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 1 van 4.

Hoe lang duurt de les “Events als bron van waarheid en het append-only-log”?

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

  1. Events als bron van waarheid en het append-only-log
  2. Aggregates, commands en modellering van domeinevents
  3. Read-modellen en projections bouwen
  4. Snapshots, versiebeheer en evolutie van eventschema's
← Terug naar Bootcamp backendontwikkeling met Node.js