Bootcamp i backendudvikling med Node.js · Lektion

Events som sandhedskilde og append-only-loggen

Erstat mutable state med en uforanderlig eventstream, og genskab state ved at afspille events igen.

Lektion 1 af 413 trin

Events som sandhedskilde og append-only-loggen er en gratis Bootcamp i backendudvikling med Node.js-lektion på CoddyKit. Dette er lektion 1 af 4. Du kan læse hele lektionen gratis nedenfor — og derefter øve dig praktisk i browseren med en indbygget kodeeditor og en AI-vejleder, der er tilgængelig døgnet rundt. Den er en del af læringsforløbet i Bootcamp i backendudvikling med Node.js, og dine fremskridt synkroniseres på tværs af nettet og CoddyKit-appen. Bootcamp i backendudvikling med Node.js-kurset indeholder 4 lektioner i alt.

Problemet med foranderlig tilstand

I en klassisk CRUD-backend gemmer du den aktuelle tilstand for en entitet og overskriver den ved hver ændring. En brugers saldo er én række; når penge flyttes, opdaterer du tallet direkte med UPDATE.

Det er praktisk, men oplysninger går tabt. Når du først har overskrevet den gamle værdi, er historikken væk. Du kan ikke svare på:

  • Hvordan nåede saldoen frem til denne værdi?
  • Hvornår og hvorfor skete hver ændring?
  • Hvordan så tilstanden ud sidste tirsdag?

Event Sourcing vender dette på hovedet: I stedet for at gemme den seneste tilstand gemmer du den sekvens af fakta, der frembragte den.

// 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.

Hændelser som sandhedskilde

I Event Sourcing er en hændelse en uforanderlig registrering af noget, der allerede er sket. Hændelser navngives i datid: MoneyDeposited, MoneyWithdrawn, AccountOpened.

Hændelsesloggen bliver sandhedskilden. Den aktuelle tilstand gemmes ikke længere direkte — den er en afledt værdi, som du beregner ved at afspille hændelserne igen.

  • Hændelser er fakta: de kan ikke ændres eller slettes.
  • Tilstanden er en fortolkning: en projektion af faktaene på et bestemt tidspunkt.

Hver hændelse indeholder hensigt og kontekst, ikke kun det resulterende tal.

// 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.`);

Loggen, der kun kan tilføjes til

Det lager, der indeholder hændelserne, er en log, der kun kan tilføjes til. Der findes kun to reelle handlinger:

  • Tilføj en ny hændelse til slutningen.
  • Læs hændelser i rækkefølge, normalt for en bestemt strøm (f.eks. én konto).

Der findes ingen UPDATE og ingen DELETE. Denne ene begrænsning giver dig et komplet, ordnet og manipulationssikkert revisionsspor uden ekstra omkostninger.

Fordi loggen er ordnet, har hver hændelses placering (eller version) betydning: Når hændelserne afspilles i samme rækkefølge, giver det altid den samme tilstand.

// 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());

Genskabelse af tilstanden ved genafspilning

Hvis tilstanden er afledt, hvordan får vi den så tilbage? Vi afspiller: Vi starter med en tom tilstand og anvender hver hændelse i rækkefølge. Denne funktion kaldes ofte apply, evolve eller en reducer.

Bemærk formen: (state, event) => newState. Det er præcis en reducer — den samme idé som Array.prototype.reduce.

  • Reduceren er ren: De samme hændelser ind giver den samme tilstand ud.
  • Den håndterer hver hændelses type og returnerer et nyt tilstandsobjekt.
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 }

Strømme og aggregater

Du afspiller sjældent alle hændelser i systemet. Hændelser grupperes i strømme, én pr. entitet — for eksempel account-acc-1. Den entitet, der genskabes fra en strøm, kaldes et aggregat.

Sådan indlæser du et aggregat:

  • Læs kun hændelserne fra den pågældende strøm (filtreret efter streamId).
  • Afspil dem gennem reduceren.
  • Returnér den resulterende tilstand i hukommelsen.

Små strømme gør genafspilningen hurtig og holder konsistensgrænserne tydelige.

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));

Kommandoer kontra hændelser

En afgørende forskel er, at en kommando er en anmodning om at gøre noget (den kan blive afvist), mens en hændelse er registreringen af, at det skete (den kan ikke omgøres).

  • Withdraw er en kommando — imperativ, i nutid og kan mislykkes.
  • MoneyWithdrawn er en hændelse — i datid og et fastslået faktum.

Aggregatets opgave er at tage den aktuelle tilstand og en kommando, anvende forretningsregler og beslutte, hvilke hændelser der skal tilføjes — eller afvise kommandoen helt.

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); }

Cyklussen Beslut / Udvikl

Når du samler delene, får du den centrale skriveproces for et hændelsesbaseret aggregat:

  • Indlæs: Afspil strømmen for at genskabe den aktuelle tilstand.
  • Beslut: Kør kommandoen mod denne tilstand for at frembringe nye hændelser.
  • Tilføj: Skriv de nye hændelser til loggen.
  • Udvikl: Den samme reducer, som indlæste tilstanden, holder den også opdateret.

Funktionen decide skriver aldrig; reduceren evolve validerer aldrig. Denne adskillelse holder domænelogikken ren og nem at teste.

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 }

Optimistisk samtidighed med forventet version

To anmodninger kan forsøge at ændre det samme aggregat på samme tid. Fordi loggen kun kan tilføjes til, beskytter vi skrivninger med den forventede version: den placering, som skriveren mener, strømmen befinder sig på.

Når du tilføjer noget, siger du: "Jeg forventer, at denne strøm er på version N." Hvis en anden skriver allerede har ført den videre, mislykkes tilføjelsen, og den kaldende part prøver igen ved at indlæse strømmen på ny.

  • Ingen låse holdes under hele anmodningen.
  • Konflikter opdages og går aldrig ubemærket tabt.
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);

Snapshots: Genafspilning uden at læse alt igen

Det bliver langsomt at genafspille tusindvis af hændelser ved hver indlæsning. Et snapshot er en cachelagret kopi af aggregatets tilstand ved en kendt version. Ved indlæsning starter du med snapshottet og afspiller kun hændelser efter det.

Vigtigt: Snapshots er en optimering, ikke en sandhedskilde. Du kan slette alle snapshots og stadig genskabe den korrekte tilstand ud fra hændelserne. Loggen er fortsat den autoritative kilde.

  • Gem snapshottet sammen med den version, det repræsenterer.
  • Ved indlæsning: Genskab fra snapshottet, og afspil derefter resten.
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 }

Skemaudvikling og upcasting

Hændelser lever for evigt, så deres struktur vil overleve den kode, der skrev dem. Du kan aldrig redigere en gammel hændelse direkte, men den nye kode skal stadig kunne forstå den.

Det almindelige værktøj er upcasting: en funktion, der omdanner en gammel hændelsesversion til den aktuelle struktur på læsetidspunktet, før den når reduceren.

  • Tilføj et version-felt til hver hændelsestype.
  • Giv nye felter fornuftige standardværdier, og omdøb dem via upcasteren.
  • Ændr aldrig gemte hændelser — omdan en kopi på vej ind.
// 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 }

Lagring af loggen i Node.js

I produktion understøttes loggen, der kun kan tilføjes til, af varig lagring: et dedikeret lager som EventStoreDB eller en relationel tabel, der bruges som log. Et typisk Postgres-design:

  • Én events-tabel med (stream_id, version, type, data jsonb, recorded_at).
  • En unik begrænsning på (stream_id, version) — det er den, der håndhæver optimistisk samtidighed på databaseniveau.
  • Kun indsættelser; applikationskoden udsteder aldrig UPDATE eller DELETE på den.

Den samme cyklus med indlæsning, beslutning og tilføjelse kører ovenpå, blot med SQL bag lagerets grænseflade.

// 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();
  }
}

Hurtigt tjek

Overvej et hændelsesbaseret kontoaggregat.

Opsummering

Du erstattede en foranderlig tilstand med en uforanderlig hændelsesstrøm:

  • Hændelser er uforanderlige fakta i datid; loggen, der kun kan tilføjes til, og som indeholder dem, er sandhedskilden.
  • Tilstanden er afledt, ikke gemt — du genskaber den ved at afspille hændelser gennem en ren reducer: (state, event) => newState.
  • Hændelser grupperes i strømme, én pr. aggregat.
  • Skrivecyklussen er indlæs → beslut → tilføj → udvikl; kommandoer kan afvises, hændelser kan aldrig afvises.
  • Forventet version giver optimistisk samtidighed; en UNIQUE(stream_id, version)-begrænsning håndhæver den i databasen.
  • Snapshots gør genafspilning hurtigere, men er valgfri; upcasting holder gamle hændelser læsbare, efterhånden som skemaerne udvikler sig.

Dernæst bygger du læsemodeller (projektioner) og færdiggør CQRS-billedet.

Gratis at komme i gang

Lær JavaScript med en AI-underviser — gratis

Skriv og kør rigtig kode i din browser, få øjeblikkelig hjælp fra en AI-underviser døgnet rundt, og fortsæt, hvor du slap, på web eller i appen.

Kurser
22
Lektioner
92

Ofte stillede spørgsmål

Er lektionen “Events som sandhedskilde og append-only-loggen” gratis?

Ja — hele teksten til “Events som sandhedskilde og append-only-loggen” kan læses gratis her på nettet. Hvis du vil øve dig interaktivt med en indbygget kodeeditor og en AI-vejleder døgnet rundt og få adgang til resten af Bootcamp i backendudvikling med Node.js-kurset, skal du opgradere til CoddyKit PRO. Bootcamp i backendudvikling med Node.js-kurset indeholder 4 lektioner i alt.

Hvad lærer jeg i “Events som sandhedskilde og append-only-loggen”?

Erstat mutable state med en uforanderlig eventstream, og genskab state ved at afspille events igen. Du øver dig i Bootcamp i backendudvikling med Node.js med praktisk kode, som du kører direkte i browseren, og en AI-vejleder døgnet rundt besvarer dine spørgsmål, mens du arbejder dig gennem lektionen.

Skal jeg have erfaring for at begynde på Bootcamp i backendudvikling med Node.js?

Der kræves ingen tidligere erfaring. Bootcamp i backendudvikling med Node.js på CoddyKit er tilrettelagt for både begyndere og øvede, så du kan starte her eller fra begyndelsen og lære i dit eget tempo. Dette er lektion 1 af 4.

Hvor lang tid tager lektionen “Events som sandhedskilde og append-only-loggen”?

De fleste CoddyKit-lektioner tager cirka 5–10 minutter. Hver lektion er kort og interaktiv, så du gør løbende fremskridt og kan fortsætte, hvor du slap – på både web og app.

Kan jeg skrive og køre kode i denne Bootcamp i backendudvikling med Node.js-lektion?

Ja. Alle Bootcamp i backendudvikling med Node.js-lektioner har en indbygget kodeeditor, så du kan skrive og køre rigtig kode direkte i din browser og få øjeblikkelig feedback fra AI – uden lokal opsætning.

Alle lektioner i dette kursus

  1. Events som sandhedskilde og append-only-loggen
  2. Aggregater, commands og modellering af domæneevents
  3. Opbygning af read models og projektioner
  4. Snapshots, versionering og udvikling af eventskemaer
← Tilbage til Bootcamp i backendudvikling med Node.js