Zum Inhalt springen

Event Sourcing in der Praxis: Wann es hilft und wann es schmerzt

Ein ausgewogener Blick auf Event Sourcing: wo es wirklich glänzt, welche Fallstricke Implementierungen scheitern lassen und welche Patterns tragen.

5 Min. Lesezeit
Event-Sourcing-Zeitstrahl, der zeigt, wie Ereignisse durch Projektionen den aktuellen Zustand aufbauen

Das Versprechen und die Falle von Event Sourcing

Event Sourcing speichert jede Zustandsänderung als unveränderliches Ereignis, anstatt den aktuellen Zustand zu überschreiben. Statt einer Zeile, die "Kontostand: $500" anzeigt, hast du eine Sequenz von Ereignissen: eingezahlt $1000, abgehoben $300, eingezahlt $200, abgehoben $400. Der aktuelle Zustand wird durch Wiedergabe der Ereignisse abgeleitet.

Das Versprechen ist eine lückenlose Audit-Historie, temporale Abfragen und die Möglichkeit, den Zustand von Grund auf neu aufzubauen. Die Falle ist der operative Mehraufwand, Herausforderungen bei der Eventual Consistency und die Versuchung, es dort einzusetzen, wo es nicht hingehört.

Wo Event Sourcing glänzt

Event Sourcing ist kein universelles Pattern. Es erzeugt den meisten Wert in Domänen, in denen die Historie der Änderungen ebenso wichtig ist wie der aktuelle Zustand.

tstypescript
interface EventSourcingFitAssessment {
  domain: string;
  fit: "excellent" | "good" | "poor";
  reason: string;
  keyBenefit: string;
}
 
const fitAssessments: EventSourcingFitAssessment[] = [
  {
    domain: "Financial transactions",
    fit: "excellent",
    reason: "Regulatory requirements demand complete audit trail",
    keyBenefit: "Immutable ledger satisfies compliance out of the box",
  },
  {
    domain: "Order management",
    fit: "excellent",
    reason: "Order lifecycle has distinct, meaningful state transitions",
    keyBenefit: "Full order history enables dispute resolution",
  },
  {
    domain: "Collaborative editing",
    fit: "good",
    reason: "Changes must be mergeable and replayable",
    keyBenefit: "Conflict resolution through event replay",
  },
  {
    domain: "User profile CRUD",
    fit: "poor",
    reason: "Current state is all that matters, changes have no business value",
    keyBenefit: "None — adds complexity without value",
  },
  {
    domain: "Static content management",
    fit: "poor",
    reason: "Low write frequency, query-heavy, history rarely needed",
    keyBenefit: "None — a simple database is better",
  },
];

Implementierung des Event Stores

Der Event Store ist die zentrale Infrastruktur von Event Sourcing. Er muss die Reihenfolge innerhalb eines Streams garantieren, optimistische Nebenläufigkeit unterstützen und eine effiziente Wiedergabe ermöglichen.

tstypescript
interface DomainEvent {
  eventId: string;
  streamId: string;
  eventType: string;
  data: Record<string, unknown>;
  metadata: {
    timestamp: Date;
    version: number;
    correlationId: string;
    causationId: string;
    userId?: string;
  };
}
 
interface EventStore {
  append(
    streamId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void>;
  readStream(
    streamId: string,
    fromVersion?: number
  ): Promise<DomainEvent[]>;
  readAll(
    fromPosition?: number,
    limit?: number
  ): Promise<DomainEvent[]>;
}
 
class PostgresEventStore implements EventStore {
  constructor(private readonly db: Database) {}
 
  async append(
    streamId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void> {
    await this.db.transaction(async (tx) => {
      // Optimistic concurrency check
      const currentVersion = await tx.queryOne<{ max_version: number }>(
        "SELECT COALESCE(MAX(version), 0) as max_version FROM events WHERE stream_id = $1",
        [streamId]
      );
 
      if (currentVersion.max_version !== expectedVersion) {
        throw new ConcurrencyError(
          `Expected version ${expectedVersion}, found ${currentVersion.max_version}`
        );
      }
 
      // Append events
      for (let i = 0; i < events.length; i++) {
        const event = events[i];
        const version = expectedVersion + i + 1;
 
        await tx.execute(
          `INSERT INTO events (event_id, stream_id, event_type, data, metadata, version)
           VALUES ($1, $2, $3, $4, $5, $6)`,
          [
            event.eventId,
            streamId,
            event.eventType,
            JSON.stringify(event.data),
            JSON.stringify({ ...event.metadata, version }),
            version,
          ]
        );
      }
    });
  }
 
  async readStream(
    streamId: string,
    fromVersion: number = 0
  ): Promise<DomainEvent[]> {
    const rows = await this.db.query(
      "SELECT * FROM events WHERE stream_id = $1 AND version > $2 ORDER BY version ASC",
      [streamId, fromVersion]
    );
 
    return rows.map(this.mapRowToEvent);
  }
 
  async readAll(fromPosition = 0, limit = 1000): Promise<DomainEvent[]> {
    const rows = await this.db.query(
      "SELECT * FROM events WHERE global_position > $1 ORDER BY global_position ASC LIMIT $2",
      [fromPosition, limit]
    );
 
    return rows.map(this.mapRowToEvent);
  }
 
  private mapRowToEvent(row: any): DomainEvent {
    return {
      eventId: row.event_id,
      streamId: row.stream_id,
      eventType: row.event_type,
      data: row.data,
      metadata: row.metadata,
    };
  }
}

Optimistische Nebenläufigkeit verhindert, dass zwei Writer gleichzeitig widersprüchliche Ereignisse an denselben Stream anhängen. Wenn die erwartete Version nicht mit der tatsächlichen übereinstimmt, schlägt die Operation fehl und der Aufrufer muss mit dem neuesten Zustand erneut versuchen.

Rekonstruktion von Aggregaten aus Ereignissen

Aggregate rehydratisieren ihren Zustand, indem sie Ereignisse aus dem Event Store wiedergeben. Jedes Ereignis wendet eine Mutation auf den Aggregatzustand an.

tstypescript
// ❌ Mutable state without event history
class MutableBankAccount {
  balance: number = 0;
 
  deposit(amount: number): void {
    this.balance += amount;
    // How did we get here? Who deposited what and when? No idea.
  }
}
 
// ✅ Event-sourced aggregate
interface AccountEvent extends DomainEvent {
  eventType: "AccountOpened" | "MoneyDeposited" | "MoneyWithdrawn" | "AccountClosed";
}
 
class BankAccount {
  private balance = 0;
  private status: "active" | "closed" = "active";
  private version = 0;
  private uncommittedEvents: DomainEvent[] = [];
 
  static fromEvents(events: DomainEvent[]): BankAccount {
    const account = new BankAccount();
    for (const event of events) {
      account.apply(event, false);
    }
    return account;
  }
 
  deposit(amount: number, correlationId: string): void {
    if (this.status !== "active") {
      throw new Error("Cannot deposit to a closed account");
    }
    if (amount <= 0) {
      throw new Error("Deposit amount must be positive");
    }
 
    this.apply(
      this.createEvent("MoneyDeposited", { amount }),
      true
    );
  }
 
  withdraw(amount: number, correlationId: string): void {
    if (this.status !== "active") {
      throw new Error("Cannot withdraw from a closed account");
    }
    if (amount > this.balance) {
      throw new Error("Insufficient funds");
    }
 
    this.apply(
      this.createEvent("MoneyWithdrawn", { amount }),
      true
    );
  }
 
  private apply(event: DomainEvent, isNew: boolean): void {
    switch (event.eventType) {
      case "AccountOpened":
        this.status = "active";
        this.balance = 0;
        break;
      case "MoneyDeposited":
        this.balance += (event.data as { amount: number }).amount;
        break;
      case "MoneyWithdrawn":
        this.balance -= (event.data as { amount: number }).amount;
        break;
      case "AccountClosed":
        this.status = "closed";
        break;
    }
 
    this.version++;
    if (isNew) {
      this.uncommittedEvents.push(event);
    }
  }
 
  private createEvent(
    eventType: string,
    data: Record<string, unknown>
  ): DomainEvent {
    return {
      eventId: crypto.randomUUID(),
      streamId: "",
      eventType,
      data,
      metadata: {
        timestamp: new Date(),
        version: this.version + 1,
        correlationId: "",
        causationId: "",
      },
    };
  }
 
  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }
 
  getBalance(): number {
    return this.balance;
  }
 
  getVersion(): number {
    return this.version;
  }
}

Projektionen: Lesemodelle aufbauen

Event Sourcing trennt Schreiben (Ereignisse) von Lesen (Projektionen). Projektionen konsumieren Ereignisse und bauen optimierte Lesemodelle für spezifische Abfragemuster auf.

tstypescript
interface Projection {
  name: string;
  handle(event: DomainEvent): Promise<void>;
  rebuild(): Promise<void>;
}
 
class AccountBalanceProjection implements Projection {
  name = "account-balances";
 
  constructor(private readonly db: Database) {}
 
  async handle(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case "AccountOpened":
        await this.db.execute(
          "INSERT INTO account_balances (account_id, balance, last_updated) VALUES ($1, 0, $2)",
          [event.streamId, event.metadata.timestamp]
        );
        break;
      case "MoneyDeposited":
        await this.db.execute(
          "UPDATE account_balances SET balance = balance + $1, last_updated = $2 WHERE account_id = $3",
          [event.data.amount, event.metadata.timestamp, event.streamId]
        );
        break;
      case "MoneyWithdrawn":
        await this.db.execute(
          "UPDATE account_balances SET balance = balance - $1, last_updated = $2 WHERE account_id = $3",
          [event.data.amount, event.metadata.timestamp, event.streamId]
        );
        break;
    }
  }
 
  async rebuild(): Promise<void> {
    await this.db.execute("TRUNCATE account_balances");
    // Replay all events from the beginning
  }
}

Häufige Fallstricke

tstypescript
interface EventSourcingPitfall {
  mistake: string;
  consequence: string;
  prevention: string;
}
 
const pitfalls: EventSourcingPitfall[] = [
  {
    mistake: "Not versioning event schemas",
    consequence: "Old events break when deserialized with new code",
    prevention: "Include schema version, use upcasters to transform old events",
  },
  {
    mistake: "Storing too much data in events",
    consequence: "Event store grows uncontrollably, replay becomes slow",
    prevention: "Store meaningful domain facts, not raw request payloads",
  },
  {
    mistake: "No snapshotting for long-lived aggregates",
    consequence: "Loading an aggregate with 10000 events takes seconds",
    prevention: "Snapshot every N events and replay only from the last snapshot",
  },
  {
    mistake: "Coupling projections to event structure",
    consequence: "Every event schema change requires updating all projections",
    prevention: "Use anti-corruption layers between events and projections",
  },
];

Wichtige Erkenntnisse

Event Sourcing ist ein mächtiges Pattern für Domänen, in denen die Historie der Änderungen wertvoll ist – Finanzsysteme, Auftragsverwaltung, kollaboratives Editieren. Es liefert lückenlose Audit-Trails, temporale Abfragen und die Möglichkeit, aus bestehenden Ereignissen neue Lesemodelle abzuleiten.

Es führt jedoch zu erheblicher Komplexität: Eventual Consistency zwischen Projektionen und Event Store, Herausforderungen bei der Schemaevolution und den operativen Overhead, Event Stores und Projektionsinfrastruktur zu betreiben. Verwende Event Sourcing nicht für einfache CRUD-Domänen, in denen nur der aktuelle Zustand zählt.

Wenn du es einsetzt, investiere in ordentliches Event Versioning, Snapshotting für langlebige Aggregate und Rebuild-Fähigkeiten für Projektionen. Die Unveränderlichkeit von Ereignissen ist sowohl die größte Stärke als auch die größte Einschränkung – du kannst ein fehlerhaftes Ereignis nicht korrigieren, sondern nur durch neue Ereignisse kompensieren.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX