Hendelser som sannhetskilde og append-only-loggen
Erstatt muterbar tilstand med en uforanderlig hendelsesstrøm, og rekonstruer tilstanden ved å spille av hendelser på nytt.
Hendelser som sannhetskilde og append-only-loggen er en gratis leksjon i Bootcamp i backendutvikling med Node.js på CoddyKit. Dette er leksjon 1 av 4. Du kan lese hele leksjonen gratis nedenfor – og deretter øve praktisk i nettleseren med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt. Den er en del av læringsløpet i Bootcamp i backendutvikling med Node.js, og fremdriften din synkroniseres mellom nettet og CoddyKit-appen. Kurset i Bootcamp i backendutvikling med Node.js inneholder totalt 4 leksjoner.
Problemet med foranderlig tilstand
I en klassisk CRUD-backend lagrer du gjeldende tilstand for en entitet og overskriver den ved hver endring. En brukers saldo er én rad; når penger flyttes, bruker du UPDATE for å endre tallet direkte.
Dette er praktisk, men medfører tap av informasjon. Når du overskriver den gamle verdien, er historikken borte. Du kan ikke svare på:
- Hvordan kom saldoen frem til denne verdien?
- Når og hvorfor skjedde hver endring?
- Hvordan så tilstanden ut forrige tirsdag?
Event Sourcing snur dette: I stedet for å lagre den nyeste tilstanden lagrer du sekvensen av fakta som frembrakte 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.Hendelser som sannhetskilden
I Event Sourcing er en hendelse en uforanderlig registrering av noe som allerede har skjedd. Hendelser navngis i fortidsform: MoneyDeposited, MoneyWithdrawn, AccountOpened.
Hendelsesloggen blir sannhetskilden. Gjeldende tilstand lagres ikke lenger direkte – den er en avledet verdi som beregnes ved å spille av hendelsene på nytt.
- Hendelser er fakta: de kan ikke endres eller slettes.
- Tilstand er en oppfatning: en projeksjon av faktaene på et bestemt tidspunkt.
Hver hendelse fanger opp hensikt og kontekst, ikke bare resultatet i form av et tall.
// 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 som bare kan utvides
Lageret som inneholder hendelser, er en logg som bare kan utvides. Det finnes bare to reelle operasjoner:
- Legg til en ny hendelse på slutten.
- Les hendelser i rekkefølge, vanligvis for en bestemt strøm (for eksempel én konto).
Det finnes ingen UPDATE og ingen DELETE. Denne ene begrensningen gir deg et komplett, ordnet og manipulasjonssikkert revisjonsspor uten ekstra kostnad.
Fordi loggen er ordnet, er posisjonen (eller versjonen) til hver hendelse viktig: Når hendelsene spilles av i samme rekkefølge, får du alltid den samme tilstanden.
// 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());Gjenopprette tilstand ved å spille av hendelser på nytt
Hvis tilstanden er avledet, hvordan får vi den tilbake? Vi spiller av hendelsene på nytt: Vi starter med en tom tilstand og bruker hver hendelse i riktig rekkefølge. Denne funksjonen kalles ofte apply, evolve eller en reducer.
Legg merke til formen: (state, event) => newState. Det er nøyaktig en reducer – den samme ideen som Array.prototype.reduce.
- Reducer-funksjonen er ren: De samme hendelsene inn gir den samme tilstanden ut.
- Den håndterer hver type av hendelse og returnerer et nytt 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ømmer og aggregater
Du spiller sjelden av hver eneste hendelse i systemet. Hendelser grupperes i strømmer, én per entitet – for eksempel account-acc-1. Entiteten som gjenopprettes fra en strøm, kalles et aggregat.
Slik laster du et aggregat:
- Les bare hendelsene i den aktuelle strømmen (filtrert med
streamId). - Spill dem av gjennom reducer-funksjonen.
- Returner den resulterende tilstanden i minnet.
Små strømmer gjør avspillingen rask og holder konsistensgrensene 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 hendelser
Et viktig skille: En kommando er en forespørsel om å gjøre noe (den kan avvises), mens en hendelse er registreringen av at det har skjedd (den kan ikke gjøres om).
Withdrawer en kommando – imperativ, i presens, og kan mislykkes.MoneyWithdrawner en hendelse – i fortidsform og et fastslått faktum.
Aggregatets oppgave er å ta den gjeldende tilstanden og en kommando, kjøre forretningsreglene og avgjøre hvilke hendelser som skal legges til – eller avvise 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); }Syklusen Decide / Evolve
Setter du delene sammen, får du den sentrale skriveflyten for et hendelsesbasert aggregat:
- Last inn: Spill av strømmen på nytt for å gjenoppbygge den gjeldende tilstanden.
- Avgjør: Kjør kommandoen mot denne tilstanden for å produsere nye hendelser.
- Legg til: Skriv de nye hendelsene til loggen.
- Utvikle: Den samme reducer-funksjonen som lastet inn tilstanden, holder den også oppdatert.
decide-funksjonen skriver aldri data; evolve-reduceren validerer aldri. Dette skillet holder domenelogikken ryddig og testbar.
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 samtidighet med forventet versjon
To forespørsler kan forsøke å endre det samme aggregatet samtidig. Fordi loggen bare kan utvides, sikrer vi skrivingen med den forventede versjonen: posisjonen som skriveren tror at strømmen har.
Når du legger til en hendelse, sier du «Jeg forventer at denne strømmen er på versjon N.» Hvis en annen skriver allerede har flyttet den fremover, mislykkes innskrivingen, og anroperen prøver på nytt ved å laste inn dataene igjen.
- Ingen låser holdes gjennom hele forespørselen.
- Konflikter oppdages og går aldri tapt i stillhet.
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);Øyeblikksbilder: Spill av uten å lese alt på nytt
Det blir tregt å spille av tusenvis av hendelser ved hver innlasting. Et øyeblikksbilde er en bufret kopi av aggregatets tilstand ved en kjent versjon. Ved innlasting starter du med øyeblikksbildet og spiller bare av hendelsene som kom etter det.
Viktig: Øyeblikksbilder er en optimalisering, ikke en sannhetskilde. Du kan slette alle øyeblikksbilder og fortsatt gjenoppbygge den korrekte tilstanden fra hendelsene. Loggen er fortsatt autoritativ.
- Lagre øyeblikksbildet og versjonen det representerer.
- Ved innlasting: Opprett tilstanden fra øyeblikksbildet, og spill deretter av 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 }Skjemautvikling og upcasting
Hendelser lever for alltid, så strukturen deres vil bestå lenger enn koden som skrev dem. Du kan aldri redigere en gammel hendelse direkte, men den nye koden må fortsatt kunne forstå den.
Standardverktøyet er upcasting: en funksjon som transformerer en gammel hendelsesversjon til den gjeldende strukturen ved lesetidspunktet, før den når reducer-funksjonen.
- Legg til et
version-felt i hver hendelsestype. - Gi nye felt fornuftige standardverdier, og gi felt nye navn via upcasteren.
- Endre aldri lagrede hendelser – transformer en kopi på vei inn.
// 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 }Lagre loggen i Node.js
I produksjon støttes loggen som bare kan utvides, av varig lagring: et dedikert lager som EventStoreDB eller en relasjonell tabell som brukes som logg. En typisk Postgres-utforming:
- Én
events-tabell med(stream_id, version, type, data jsonb, recorded_at). - En unik begrensning på
(stream_id, version)– det er denne som håndhever optimistisk samtidighet på databasenivå. - Bare innsettinger; applikasjonskoden sender aldri
UPDATEellerDELETEmot tabellen.
Den samme last inn/avgjør/legg til-syklusen kjøres på toppen, men med SQL bak lagergrensesnittet.
// 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();
}
}Rask kontroll
Se for deg et hendelsesbasert kontoaggregat.
Oppsummering
Du erstattet en foranderlig tilstand med en uforanderlig hendelsesstrøm:
- Hendelser er uforanderlige fakta i fortidsform; loggen som bare kan utvides, og som inneholder dem, er sannhetskilden.
- Tilstanden er avledet og lagres ikke – du gjenoppbygger den ved å spille av hendelser gjennom en ren reducer:
(state, event) => newState. - Hendelser grupperes i strømmer, én per aggregat.
- Skrivesyklusen er last inn → avgjør → legg til → utvikle; kommandoer kan avvises, hendelser kan aldri avvises.
- Forventet versjon gir optimistisk samtidighet; en
UNIQUE(stream_id, version)-begrensning håndhever dette i databasen. - Øyeblikksbilder gjør avspillingen raskere, men er valgfrie; upcasting gjør gamle hendelser lesbare når skjemaene utvikler seg.
Deretter skal du bygge lesemodeller (projeksjoner) og fullføre CQRS-bildet.
Lær deg JavaScript med en AI-veileder – gratis
Skriv og kjør ekte kode i nettleseren, få umiddelbar hjelp fra en AI-veileder som er tilgjengelig døgnet rundt, og fortsett der du slapp – på nettet eller i appen.
- Kurs
- 22
- Leksjoner
- 92
Ofte stilte spørsmål
Er leksjonen «Hendelser som sannhetskilde og append-only-loggen» gratis?
Ja – hele teksten i «Hendelser som sannhetskilde og append-only-loggen» er gratis å lese her på nettet. For å øve interaktivt med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt, og for å låse opp resten av Bootcamp i backendutvikling med Node.js-kurset, kan du oppgradere til CoddyKit PRO. Kurset i Bootcamp i backendutvikling med Node.js inneholder totalt 4 leksjoner.
Hva lærer jeg i «Hendelser som sannhetskilde og append-only-loggen»?
Erstatt muterbar tilstand med en uforanderlig hendelsesstrøm, og rekonstruer tilstanden ved å spille av hendelser på nytt. Du øver på Bootcamp i backendutvikling med Node.js med praktisk kode som du kjører direkte i nettleseren, mens en AI-veileder som er tilgjengelig døgnet rundt, svarer på spørsmålene dine mens du jobber deg gjennom leksjonen.
Trenger jeg erfaring for å begynne med Bootcamp i backendutvikling med Node.js?
Ingen tidligere erfaring er nødvendig. Bootcamp i backendutvikling med Node.js på CoddyKit er lagt opp for både nybegynnere og viderekomne, så De kan begynne her eller helt fra start og lære i Deres eget tempo. Dette er leksjon 1 av 4.
Hvor lang tid tar leksjonen «Hendelser som sannhetskilde og append-only-loggen»?
De fleste CoddyKit-leksjoner tar omtrent 5–10 minutter. Hver leksjon er kort og interaktiv, slik at De gjør jevne fremskritt og kan fortsette akkurat der De slapp – både på nettet og i appen.
Kan jeg skrive og kjøre kode i denne Bootcamp i backendutvikling med Node.js-leksjonen?
Ja. Alle Bootcamp i backendutvikling med Node.js-leksjoner har en innebygd kodeeditor, slik at De kan skrive og kjøre ekte kode direkte i nettleseren og få umiddelbar tilbakemelding fra AI – uten lokal konfigurering.
Alle leksjonene i dette kurset
- Hendelser som sannhetskilde og append-only-loggen
- Aggregater, kommandoer og modellering av domenebegivenheter
- Bygging av lesemodeller og projeksjoner
- Snapshots, versjonering og utvikling av hendelsesskjemaer