Event sourcing in Node.js: praktische gids

Leer hoe event sourcing in Node.js werkt. Van events en aggregates tot replay, snapshots en projections, met praktische code en best practices.

28 juli 20267 min leestijdDoor We Develop Communication

Stel je voor: een klant claimt dat een bestelling niet is verwerkt, maar jouw database toont een andere realiteit. Zonder historie sta je met lege handen. Hier komt event sourcing in Node.js om de hoek kijken, een architectuurpatroon waarbij je elke verandering vastlegt als een onveranderlijk event, in plaats van alleen de huidige staat.

In deze gids leer je wat event sourcing is, hoe je het praktisch implementeert in Node.js, en wanneer het de complexiteit wel (of juist niet) waard is. We behandelen events, aggregates, replay, snapshots en projections, met concrete code die je direct kunt gebruiken.

Wat is event sourcing precies?

Traditioneel sla je in een database de huidige staat op. Update je een bestelling? Dan overschrijf je de oude waarde. Bij event sourcing draai je dat om: elke verandering is een event dat je toevoegt aan een append-only log.

De huidige staat is niet iets wat je opslaat, maar iets wat je afleidt door alle events in volgorde te verwerken. Dat klinkt contra-intuïtief, maar levert verrassend veel op.

Een simpel voorbeeld. Een winkelmandje krijgt de volgende events:

  • CartCreated
  • ItemAdded (product: "Shirt")
  • ItemAdded (product: "Broek")
  • ItemRemoved (product: "Shirt")
  • CartCheckedOut

Door deze events opnieuw af te spelen, weet je niet alleen dat er een broek is gekocht, maar ook dat de klant eerst een shirt had toegevoegd en weer verwijderd. Die historie is goud waard voor analytics, debugging en compliance.

Waarom event sourcing overwegen?

Voordat we in de code duiken: waarom zou je deze complexiteit omarmen?

Complete audit trail. Je weet niet alleen wat de huidige staat is, maar ook hoe je daar bent gekomen. Voor domeinen als finance, healthcare en logistiek is dat geen luxe maar een vereiste.

Temporele queries. Wat was de voorraad op 12 maart om 14:00? Met een traditionele database lastig. Met event sourcing speel je events af tot dat moment en je hebt je antwoord.

Debugging superpowers. Een bug in productie? Je kunt de exacte event-stream reproduceren op een lokale omgeving en stap voor stap zien wat er gebeurde.

Flexibele read models. Dezelfde events kunnen meerdere projections voeden: een zoek-index, een rapportage-database, een cache. Elk geoptimaliseerd voor zijn use case.

De keerzijde: meer complexiteit, een steilere leercurve en eventual consistency tussen write- en read-kant. Niet elk project heeft dit nodig, voor simpele CRUD blijft een traditionele aanpak vaak beter.

De bouwblokken: events, aggregates en event store

Event sourcing kent een paar kernconcepten. Laten we ze een voor een pakken.

Events

Een event is een feit uit het verleden. Het is immutable en beschrijft iets wat is gebeurd. Namen gebruik je in de verleden tijd: OrderPlaced, PaymentReceived, ItemShipped.

type DomainEvent = {
  id: string;
  type: string;
  aggregateId: string;
  version: number;
  timestamp: Date;
  payload: Record<string, unknown>;
};

type ItemAdded = DomainEvent & {
  type: 'ItemAdded';
  payload: {
    productId: string;
    quantity: number;
    price: number;
  };
};

Zorg dat je events goed typeert. Combineer dit met validatie met Zod om runtime-garanties te krijgen dat events voldoen aan je schema.

Aggregates

Een aggregate is een cluster van domein-objecten dat je als één geheel behandelt. Het beschermt business-invarianten en produceert events op basis van commands.

class ShoppingCart {
  private items: Map<string, number> = new Map();
  private version = 0;
  private changes: DomainEvent[] = [];

  constructor(public readonly id: string) {}

  addItem(productId: string, quantity: number, price: number) {
    if (quantity <= 0) throw new Error('Quantity must be positive');

    this.apply({
      id: crypto.randomUUID(),
      type: 'ItemAdded',
      aggregateId: this.id,
      version: this.version + 1,
      timestamp: new Date(),
      payload: { productId, quantity, price },
    });
  }

  private apply(event: DomainEvent) {
    this.mutate(event);
    this.changes.push(event);
  }

  private mutate(event: DomainEvent) {
    if (event.type === 'ItemAdded') {
      const { productId, quantity } = event.payload as any;
      this.items.set(productId, (this.items.get(productId) ?? 0) + quantity);
    }
    this.version = event.version;
  }

  static fromHistory(id: string, events: DomainEvent[]): ShoppingCart {
    const cart = new ShoppingCart(id);
    for (const event of events) cart.mutate(event);
    return cart;
  }

  pullChanges(): DomainEvent[] {
    const changes = this.changes;
    this.changes = [];
    return changes;
  }
}

De event store

De event store is een append-only log. Elk event wordt toegevoegd, niets wordt ooit aangepast of verwijderd. PostgreSQL is hiervoor uitstekend geschikt.

CREATE TABLE events (
  id UUID PRIMARY KEY,
  aggregate_id UUID NOT NULL,
  type TEXT NOT NULL,
  version INT NOT NULL,
  payload JSONB NOT NULL,
  timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (aggregate_id, version)
);

CREATE INDEX idx_events_aggregate ON events (aggregate_id, version);

De unieke constraint op (aggregate_id, version) is cruciaal: die voorkomt concurrent writes die elkaars wijzigingen overschrijven. Meer over het werken met databases in Node.js vind je in onze aparte gids.

Events opslaan en laden

Laten we een eenvoudige repository bouwen die aggregates kan laden en opslaan.

import { Pool } from 'pg';

class CartRepository {
  constructor(private pool: Pool) {}

  async load(id: string): Promise<ShoppingCart | null> {
    const { rows } = await this.pool.query(
      'SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version ASC',
      [id]
    );
    if (rows.length === 0) return null;

    const events = rows.map(r => ({
      id: r.id,
      type: r.type,
      aggregateId: r.aggregate_id,
      version: r.version,
      timestamp: r.timestamp,
      payload: r.payload,
    }));

    return ShoppingCart.fromHistory(id, events);
  }

  async save(cart: ShoppingCart): Promise<void> {
    const changes = cart.pullChanges();
    if (changes.length === 0) return;

    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      for (const event of changes) {
        await client.query(
          `INSERT INTO events (id, aggregate_id, type, version, payload, timestamp)
           VALUES ($1, $2, $3, $4, $5, $6)`,
          [event.id, event.aggregateId, event.type, event.version,
           event.payload, event.timestamp]
        );
      }
      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }
}

De transactie is belangrijk: of alle events van één command worden opgeslagen, of geen. Als de unieke constraint faalt (concurrent write), krijg je een conflict dat je moet afhandelen, meestal door te herladen en de operatie opnieuw te proberen.

Snapshots: waarom en wanneer

Wat als een aggregate duizenden events heeft? Dan wordt elke load traag. De oplossing: snapshots.

Een snapshot is een periodieke momentopname van de aggregate-state. Bij laden pak je de laatste snapshot en speel je alleen de events na die snapshot af.

async load(id: string): Promise<ShoppingCart | null> {
  const snapshot = await this.loadSnapshot(id);
  const fromVersion = snapshot?.version ?? 0;

  const { rows } = await this.pool.query(
    `SELECT * FROM events
     WHERE aggregate_id = $1 AND version > $2
     ORDER BY version ASC`,
    [id, fromVersion]
  );

  const cart = snapshot
    ? ShoppingCart.fromSnapshot(snapshot)
    : new ShoppingCart(id);

  for (const row of rows) cart.applyEvent(row);
  return cart;
}

Een veelgebruikte vuistregel: maak een snapshot elke 100 events. Meet vooraf wat nodig is, voortijdig optimaliseren is ook hier een valkuil.

Projections: events naar leesmodellen vertalen

Events zijn ideaal voor schrijven, maar onhandig voor queries zoals "alle bestellingen van deze klant deze maand". Daarvoor gebruik je projections: consumers die events verwerken en er een leesmodel van maken.

class CartSummaryProjection {
  constructor(private pool: Pool) {}

  async handle(event: DomainEvent) {
    switch (event.type) {
      case 'ItemAdded':
        await this.pool.query(
          `INSERT INTO cart_summaries (cart_id, item_count, total)
           VALUES ($1, $2, $3)
           ON CONFLICT (cart_id) DO UPDATE
           SET item_count = cart_summaries.item_count + $2,
               total = cart_summaries.total + $3`,
          [event.aggregateId,
           (event.payload as any).quantity,
           (event.payload as any).price]
        );
        break;
      case 'CartCheckedOut':
        await this.pool.query(
          `UPDATE cart_summaries SET status = 'checked_out' WHERE cart_id = $1`,
          [event.aggregateId]
        );
        break;
    }
  }
}

Projections zijn idempotent: als je hetzelfde event twee keer verwerkt, moet de uitkomst gelijk blijven. Gebruik event-id's om duplicates te detecteren.

Voor productie draai je projections vaak als aparte background workers die events consumeren uit een stream of queue. Denk aan Kafka-integratie als backbone, waarmee je meerdere services dezelfde event-stream kunt laten consumeren.

Events publiceren naar andere services

In een microservices-landschap wil je events vaak ook buiten je eigen service delen. Een populaire aanpak is het outbox pattern: je schrijft events in dezelfde transactie als je state, en een aparte worker leest ze en publiceert ze naar een message broker.

Zo voorkom je dual-write problemen waarbij je database-commit slaagt maar de publish mislukt (of andersom). Voor realtime use cases kun je je projections direct naar clients pushen via WebSockets.

Valkuilen om te vermijden

Event sourcing is krachtig, maar kent ook scherpe randen.

Event schema evolution. Events leven voor altijd. Een veld hernoemen is geen optie, je moet upcasters schrijven die oude events vertalen naar het nieuwe formaat. Denk vooraf na over je event-design.

Grote aggregates. Een aggregate met 100.000 events is een probleem. Splits je aggregate-boundaries goed en gebruik snapshots als events toch oplopen.

Eventual consistency. Je leesmodel loopt altijd een fractie achter op je schrijfmodel. Klanten die net een bestelling hebben geplaatst en die direct niet zien verschijnen, zijn verward. Bouw UI-compensaties in.

Over-engineering. Niet elk domein heeft event sourcing nodig. Voor een simpele admin-tool is het overkill. Kies het bewust, per bounded context.

Voor meer algemene architectuur-tips over grote Node.js codebases, bekijk onze monorepo architectuur-gids en de production readiness checklist.

Tooling in het Node.js ecosysteem

Je hoeft niet alles zelf te bouwen. Populaire opties:

  • EventStoreDB, purpose-built event store met een Node.js client.
  • Emmett, een TypeScript-first event sourcing framework, lightweight en flexibel.
  • PostgreSQL + custom code, prima voor kleinere tot middelgrote systemen, zoals in deze gids.

Voor referentie is het Microsoft .NET event sourcing patterns document ook in een Node.js-context leerzaam, de patronen zijn taal-onafhankelijk.

Wanneer is event sourcing de juiste keuze?

Overweeg event sourcing wanneer:

  • Je domein inherent gebeurtenis-gedreven is (finance, logistiek, booking-systemen)
  • Audit trails een harde eis zijn (compliance, healthcare)
  • Je temporele queries nodig hebt
  • Je meerdere read-modellen wilt voeden vanuit één bron van waarheid

Kies het niet voor simpele CRUD, MVP's waarbij snelheid boven alles gaat, of teams die nog bezig zijn met de basisbeginselen van distributed systems. Combineer het met goede error handling op schaal zodat je replay-scenario's voorspelbaar blijven.

Veelgestelde vragen

Wat is event sourcing?

Event sourcing is een patroon waarbij je de staat van je applicatie niet opslaat als huidige waarde, maar als een reeks onveranderlijke events. De huidige staat leid je af door al die events in volgorde af te spelen.

Wanneer gebruik je event sourcing in Node.js?

Gebruik event sourcing als je een volledige audit trail nodig hebt, complexe business flows wilt modelleren of temporele queries wilt uitvoeren. Voor simpele CRUD-apps is het vaak overkill.

Wat is het verschil tussen event sourcing en CQRS?

CQRS scheidt lees- en schrijfmodellen, terwijl event sourcing bepaalt hoe je state opslaat. Ze worden vaak samen gebruikt: events zijn het schrijfmodel, projections zijn het leesmodel.

Wat zijn snapshots in event sourcing?

Snapshots zijn periodieke momentopnames van de staat van een aggregate. Ze voorkomen dat je bij elke load duizenden events opnieuw moet replayen en versnellen daarmee je reads.

Welke database past bij event sourcing in Node.js?

PostgreSQL is een solide keuze vanwege transacties en append-only patterns. Ook EventStoreDB is populair, net als MongoDB. De keuze hangt af van je schaal en team-ervaring.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.