Was event-driven architecture eigentlich bedeutet
Event-driven architecture ist ein Kommunikationsstil, bei dem Services Events aufzeichnen – Aussagen in der Vergangenheitsform, dass etwas passiert ist – und auf diese reagieren, anstatt sich gegenseitig direkt aufzurufen. Ein Order-Service greift nicht in einen Billing-Service ein und sagt „stelle das in Rechnung“. Er zeichnet order.placed auf und macht weiter. Ein Billing-Service, für den diese Tatsache relevant ist, abonniert dieses Event und erstellt die Rechnung zu seinem eigenen Zeitpunkt.
Das entscheidende Wort ist Tatsache. Ein Event ist unveränderlich (immutable) und bereits eingetreten. order.placed beschreibt etwas, das passiert ist; kein Consumer kann es vetoieren, und kein Producer wartet auf eine Antwort. Ein Request ist eine Frage mit einer Antwort, und der Aufrufer ist blockiert, bis diese eintrifft. Ein Event ist eine Aussage mit einem Publikum, und der Producer ist fertig, sobald die Aussage dauerhaft gespeichert (durable) ist.
Dieser Unterschied klingt gering, verändert aber alles. Da der Producer niemanden aufruft, muss er nicht wissen, wer an der Information interessiert ist. Da Consumer nicht antworten, können sie langsam sein, neu gestartet oder später hinzugefügt werden, ohne dass der Producer dies bemerkt. Der Preis dafür ist, dass das System keinen einzelnen Kontrollfluss mehr hat, dem man folgen kann, und die Korrektheit nun davon abhängt, dass jeder Teilnehmer Duplikate, Verzögerungen und eine geänderte Reihenfolge elegant handhabt.
In diesem Guide geht es um die Mechanismen, die diese Garantien ermöglichen, und um die Fälle, in denen sich dieser Tausch nicht lohnt.
Commands weisen an, Events beschreiben
Die häufigste Quelle für Verwirrung in event-gesteuerten Systemen ist es, beide Arten von Nachrichten als „Events“ zu bezeichnen. Halten Sie diese strikt getrennt.
Ein Command ist eine Anweisung: reserve-inventory, charge-card, send-welcome-email. Es ist imperativ, es richtet sich an einen Handler, von dem eine Aktion erwartet wird, und es kann fehlschlagen oder abgelehnt werden. Ein Command hat genau einen Besitzer, und der Absender ist in der Regel am Ergebnis interessiert.
Ein Event ist eine Beschreibung: inventory-reserved, card-charged, user-registered. Es steht in der Vergangenheitsform, es ist eine Tatsache, die dem Producer gehört, und es kann null, einen oder tausend Consumer haben. Kein Consumer kann es ablehnen, da es bereits eingetreten ist. Der Producer weiß weder, wer es liest, noch ist es ihm wichtig.
// A command asks for something and expects a handler to decide.
await commands.send("reserve-inventory", { orderId, quantity: 2 });
// An event reports that a decision was made. Nobody may reject it.
await events.publish("inventory.reserved", { orderId, quantity: 2 });
Bei der Benennung geht es nicht um Pedanterie. Ein Channel voller Commands ist ein verteilter Prozeduraufruf und bringt die gesamte damit verbundene Kopplung mit sich. Ein Channel voller Events ist ein Broadcast und kann erweitert werden, ohne dass eine Erlaubnis eingeholt werden muss. Wenn ein Nachrichtenname keine Zeitform hat — inventory-reservation — kann niemand, der den Code später liest, erkennen, ob es sich um eine Anfrage oder eine Tatsache handelt.
Ein nützlicher Test: Kann der Absender fortfahren, ohne das Ergebnis zu kennen? Wenn ja, ist es wahrscheinlich ein Event. Wenn der Absender basierend auf dem Ergebnis verzweigen muss, ist es ein Command, und Sie sollten es an einen einzelnen Handler routen, anstatt es per Broadcast zu versenden.
Es ist üblich, beides in einem Flow zu benötigen. Ein Checkout-Service sendet einen charge-card Command an den Payment-Service und wartet auf die Antwort, da er die Bestellung ohne diese nicht bestätigen kann. Sobald die Zahlung erfolgreich war, veröffentlicht der Payment-Service payment.captured, und jede andere interessierte Partei reagiert asynchron. Das Command ist das synchrone Rückgrat; die Events sind der Fan-out. Es ist völlig in Ordnung, beides bewusst zu mischen — der Fehler liegt darin, vorzugeben, ein Command sei ein Event, und dann überrascht zu sein, wenn niemand antwortet.
Events, Event Sourcing und CQRS sind drei verschiedene Konzepte
Diese drei Ansätze hängen zusammen, werden häufig gemeinsam eingesetzt, sind aber vollständig voneinander trennbar. Sie als eine einzige Sache zu betrachten, ist der schnellste Weg, ein System zu bauen, das komplizierter ist als das eigentliche Problem.
Event-driven ist ein Kommunikationsstil. Services tauschen Fakten über einen Broker aus. Die Datenbank bleibt die Source of Truth; man könnte den Broker morgen entfernen und würde lediglich die Entkopplung verlieren.
Event Sourcing ist ein Persistenzstil. Anstatt den aktuellen Zustand einer Zeile zu speichern, speichert man die geordnete Sequenz von Events, die zu diesem Zustand geführt haben. Der aktuelle Zustand ist das Ergebnis eines Folds über diese Sequenz. Das Log ist die Source of Truth. Die Rekonstruktion eines Kontostands bedeutet beispielsweise, alle Ein- und Auszahlungen erneut abzuspielen. Dies bietet einen perfekten Audit-Trail und die Fähigkeit, in der Zeit zurückzureisen – auf Kosten komplexerer Reads und schwierigerer Migrationen.
CQRS (Command Query Responsibility Segregation) befasst sich mit der Trennung des Schreibmodells vom Lesemodell. Commands durchlaufen ein Modell, das für Validierungen und Invarianten optimiert ist; Queries lesen aus einer oder mehreren Projections, die für die Abfrage optimiert sind. Die beiden Modelle können dieselbe Datenbank nutzen oder komplett unterschiedliche Stores verwenden.
Man kann jedes dieser Konzepte unabhängig von den anderen einführen:
- Event-driven ohne Event Sourcing: Services veröffentlichen Fakten, behalten aber jeweils eine normale Tabelle bei.
- Event Sourcing ohne Broker: Ein einzelner Service speichert seine Events in seiner eigenen Datenbank.
- CQRS ohne Events: Zwei Modelle über denselben Daten, die synchron synchronisiert werden.
Die meisten Teams sollten mit einfacher event-driven Kommunikation und einer normalen Datenbank beginnen. Event Sourcing ist eine ernsthafte Verpflichtung; es nur deshalb einzuführen, weil die Architektur beeindruckend klingt, führt zuverlässig zu Reue.
Pub/sub, Topics und Fan-out
Der Transportmechanismus, der Events ermöglicht, ist publish/subscribe. Producer veröffentlichen Nachrichten in einem benannten Kanal, meist topic oder Exchange genannt, und Consumer abonnieren die Topics, die für sie relevant sind. Der Broker übernimmt das Routing.
Die entscheidende Eigenschaft ist, dass der Producer die Consumer nicht direkt anspricht. Er veröffentlicht einmal an orders, und der Broker liefert die Nachricht an jedes Abonnement aus: Abrechnung, Fulfillment, Suchindexierung, Analytics, Betrugserkennung. Das Hinzufügen eines sechsten Consumers ist eine Konfigurationsänderung bei diesem Consumer, keine Code-Änderung im Producer.
// One publish, many independent subscribers.
await broker.publish("orders", {
type: "order.placed",
data: { orderId, customerId, totalCents },
});
Es gibt zwei grundlegende Arten von Brokern, und die Wahl beeinflusst, was Sie bauen können:
- Log-basierte Broker wie Kafka speichern jedes Event für ein bestimmtes Aufbewahrungsfenster (Retention Window) und erlauben jeder Consumer-Gruppe, ihre eigene Position zu tracken. Consumer können die Historie erneut abspielen (Replay), und viele Gruppen lesen dasselbe Topic unabhängig voneinander.
- Queue- oder Exchange-basierte Broker wie RabbitMQ routen jede Nachricht an eine oder mehrere Queues; eine Nachricht wird typischerweise entfernt, sobald sie bestätigt (acknowledged) wurde. Das Routing ist sehr flexibel, aber Replay ist hier nicht das Modell.
Ein Topic sollte nach der Tatsache benannt werden, die es transportiert, nicht nach dem Consumer, der es liest. orders und users altern gut; billing-inbox hingegen nicht, denn an dem Tag, an dem ein zweiter Consumer auftaucht, ist der Name eine Lüge. Halten Sie Topics stabil und lassen Sie die Abonnements die Variable sein, die sich ändert.
Das Acknowledgement-Modell entscheidet über die Zustellgarantien. Ein Consumer, der die Nachricht bestätigt, bevor er die Arbeit erledigt, riskiert, bei einem Absturz ein Event zu verlieren; einer, der erst nach der Arbeit bestätigt, riskiert, dass das Event doppelt verarbeitet wird. Fast jeder Broker nutzt standardmäßig die zweite Variante, weshalb Idempotenz nicht optional ist. Einige Systeme erlauben es zudem, dass ein logisches Abonnement ein Event nur einmal erhält, selbst wenn viele konkurrierende Consumer vorhanden sind — eine Work Queue —, während andere jedem Subscriber eine eigene Kopie senden — ein Broadcast. Wissen Sie genau, welches Modell ein Topic bietet, bevor Sie sich darauf verlassen.
Eventual Consistency und warum Consumer hinterherhinken
In dem Moment, in dem ein Producer aufhört zu warten, wird das System eventually consistent. Nachdem order.placed committet wurde, existiert die Bestellung sofort in der Orders-Datenbank, aber noch nicht in der Rechnung, im Suchindex oder im Analytics-Warehouse. Es gibt ein Zeitfenster – Millisekunden unter normaler Last, Minuten während eines Incidents –, in dem diese Views voneinander abweichen.
Dies ist kein Defekt, den man verstecken muss; es ist die definierende Eigenschaft dieses Architekturstils. Jeder Read-Pfad, der auf einem Event basiert, muss zwei Fragen beantworten: Wie veraltet dürfen die Daten sein und was sieht der Nutzer in der Zwischenzeit?
Einige Gewohnheiten machen dies handhabbar:
- Read-your-own-writes aus der Quelle. Nachdem ein Nutzer eine Aktion ausgeführt hat, leiten Sie ihn auf eine View weiter, die vom Write-Modell bedient wird, und nicht von einer Projection, die noch nicht auf dem aktuellen Stand ist.
- Ehrliche Status anzeigen. Ein
202 Acceptedmitstatus: "processing"ist besser als eine Seite, die zwischen leer und befüllt hin- und herspringt. - Lag messen. Die Lücke zwischen dem neuesten veröffentlichten Event und der Position eines Consumers ist das nützlichste Health-Signal im gesamten System.
Ein konkretes Beispiel macht dieses Zeitfenster greifbar. Ein Kunde gibt eine Bestellung auf und die Bestätigungsseite wird vom Orders-Service bedient, daher ist sie sofort korrekt. Seine Account-Seite wird hingegen von einer Projection bedient, die aus order.placed aufgebaut wurde; für die nächsten zweihundert Millisekunden zeigt sie also keine Bestellungen an. Wenn die Projection während eines Deploys einige Sekunden hinterherhinkt, sieht der Kunde „keine Bestellungen“ und erstellt ein Support-Ticket. Nichts davon ist ein Bug im Event-Flow; es ist der Flow, der genau wie geplant funktioniert, und die UI muss entsprechend darauf ausgelegt sein.
Lag ist normal und wächst aus ganz gewöhnlichen Gründen: ein Traffic-Peak, eine langsame Downstream-API, ein Consumer-Neustart oder ein Rebalance. Ein Lag, der unbegrenzt wächst, ist ein Kapazitätsproblem – und es bleibt unsichtbar, sofern man es nicht auf einem Dashboard visualisiert. Eine gute Projection trackt ihre eigene Position und stellt diese bereit, sodass die Lücke eine Zahl ist, für die man Alerts definieren kann, anstatt ein Gefühl, das man erst durch Kundenbeschwerden entdeckt.
Es gibt eine Konsistenzgarantie, die es selbst in einem eventually consistent System wert ist, beibehalten zu werden: monotonic reads. Ein Consumer sollte sich niemals rückwärts bewegen. Wenn ein Event mit einem älteren Zeitstempel eintrifft als eines, das bereits angewendet wurde, kann das Anwenden in der falschen Reihenfolge gelöschte Daten wiederbeleben oder einen Counter zurücksetzen. Versionieren Sie Ihre Projections über die Sequenznummer oder den Offset, nicht über die Uhrzeit, und ignorieren Sie alles, was älter ist als das, was Sie bereits verarbeitet haben.
Das Dual-Write-Problem und die Transactional Outbox
Hier ist der Fehler, in den fast jeder tappt: Ein Service muss seine Datenbank aktualisieren und gleichzeitig ein Event veröffentlichen. Er schreibt die Zeile und ruft anschließend den Broker auf. Was passiert, wenn das Publishing fehlschlägt? Was, wenn der Prozess zwischen diesen beiden Schritten beendet wird?
- Erst Commit, dann Publish: Die Zeile existiert, das Event wurde jedoch nie versendet, und nachgelagerte Services übersehen die Änderung stillschweigend.
- Erst Publish, dann Commit: Das Event kündigt einen Zustand an, der nie gespeichert wurde, und die Consumer agieren auf Basis einer Information, die nicht wahr ist.
Es gibt keine Möglichkeit, einen Datenbank-Schreibvorgang und ein Netzwerk-Publishing atomar zu gestalten. Dies ist das Dual-Write-Problem, und es lässt sich nicht lösen, indem man die Reihenfolge der beiden Aufrufe sorgfältiger wählt. Ein Broker, der Transaktionen unterstützt, hilft hier nicht weiter, da die Datenbank ein separates System ist.
Die Standardlösung ist die Transactional Outbox. Anstatt das Event direkt zu veröffentlichen, schreiben Sie das Event in eine outbox-Tabelle innerhalb derselben Transaktion wie die Zustandsänderung. Entweder werden beide Zeilen committet oder keine von beiden. Ein separater Relay – ein Polling-Worker oder ein Change-Data-Capture-Connector, der das Datenbank-Log mitliest – liest die nicht veröffentlichten Zeilen aus und sendet sie an den Broker.
BEGIN;
INSERT INTO orders (id, customer_id, status, total_cents)
VALUES ($1, $2, 'placed', $3);
INSERT INTO outbox (id, topic, payload)
VALUES ($1, 'orders', $2);
COMMIT;
Der Relay löscht die Zeilen nach der Veröffentlichung oder markiert sie als erledigt. Wenn er nach dem Publishing, aber vor dem Markieren abstürzt, wird das Event zweimal veröffentlicht – und genau deshalb müssen Consumer idempotent sein. Wenn er vor dem Publishing abstürzt, bleibt die Zeile erhalten und wird beim nächsten Durchlauf erfasst. In jedem Fall geht kein Event verloren.
Zwei Details sind dabei wichtig: Der Relay sollte Zeilen mittels FOR UPDATE SKIP LOCKED (oder einem Äquivalent) beanspruchen, damit mehrere Relay-Instanzen nicht gleichzeitig dieselbe Zeile veröffentlichen. Zudem sollte die Outbox bereinigt werden, da eine Tabelle, die nur wächst, irgendwann das größte Objekt in Ihrer Datenbank sein wird.
At-least-once delivery und idempotente Consumer
Jeder Broker, der sich lohnt, bietet at-least-once delivery. Er bietet kein exactly-once, da exactly-once über ein Netzwerk und einen Absturz hinweg faktisch unmöglich ist. Ein Consumer kann ein Event verarbeiten, vor der Bestätigung abstürzen und es nach dem Neustart erneut erhalten. Ein Outbox-Relay kann eine Zeile doppelt veröffentlichen. Ein Producer kann einen Timeout erneut versuchen, der eigentlich erfolgreich war.
Das ist der Vertrag, kein Bug. Ihre Consumer müssen idempotent sein: Die zweimalige Verarbeitung desselben Events muss zum gleichen Endzustand führen wie die einmalige Verarbeitung.
Es gibt drei praktische Patterns:
Natürliche Idempotenz. Einige Operationen sind bereits sicher zu wiederholen. Einen Status zweimal auf shipped zu setzen, ist dasselbe wie einmal. Ein Insert mit ON CONFLICT DO UPDATE konvergiert. Bevorzugen Sie diese Ansätze, wo immer möglich.
Eine Deduplizierungstabelle. Protokollieren Sie jede verarbeitete Event-ID mit einem Unique-Constraint in derselben Transaktion wie die eigentliche Arbeit. Wenn das Insert einen Konflikt verursacht, wurde das Event bereits bearbeitet und der Consumer bricht vorzeitig ab. Dies ist die universelle Lösung und diejenige, nach der man zuerst greifen sollte.
Provider-Idempotenzschlüssel. Payment-Gateways und viele APIs akzeptieren einen stabilen Schlüssel und geben das ursprüngliche Ergebnis zurück, anstatt den Seiteneffekt zu wiederholen. Kombinieren Sie dies mit Ihrer eigenen Deduplizierung, da der Schlüssel nur den Aufruf schützt, nicht die umgebende Logik.
const seen = await client.query(
`INSERT INTO processed_events (event_id, consumer)
VALUES ($1, 'billing')
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id],
);
if (seen.rowCount === 0) return { skipped: true };
Beachten Sie, dass der Deduplizierungsschlüssel die Event-ID ist, nicht die Order-ID. Das macht den Consumer sicher, selbst wenn der Producer legitim zwei verschiedene Events zur selben Bestellung veröffentlicht — order.placed und order.cancelled sind unterschiedliche Fakten und beide sollten verarbeitet werden.
Idempotenz ist eine Eigenschaft des Effekts, nicht des Transports. Ein Broker kann doppelte Event-IDs am Edge filtern, was hilfreich ist, aber er kann nicht wissen, ob Ihr Handler bereits eine E-Mail gesendet oder eine Karte belastet hat. Nur der Consumer, innerhalb derselben Transaktion wie sein Seiteneffekt, kann das entscheiden. Deshalb gehört die Deduplizierung direkt neben den Schreibvorgang und nicht in eine middleware-Schicht, die davor ausgeführt wird.
Reihenfolge und Partitionierung
Ein Broker, der Daten über viele Partitionen verteilt (Fan-out), kann keine globale Reihenfolge garantieren. Kafka sortiert Records innerhalb einer Partition; RabbitMQ sortiert innerhalb einer Queue, die von einem einzigen Consumer bedient wird. Systemübergreifend kommen Events in der Reihenfolge an, die das Netzwerk und das Scheduling zulassen.
Das ist in Ordnung, solange Sie einen Partition Key wählen, der der benötigten Reihenfolge Ihrer Consumer entspricht. Veröffentlichen Sie jedes Event für einen Kunden mit key = customerId, sodass alle Events dieses Kunden in derselben Partition landen und in der richtigen Reihenfolge verarbeitet werden. Verschiedene Kunden werden über die Partitionen verteilt und parallel verarbeitet.
await producer.publish("orders", {
key: event.data.customerId, // ordering is per key
value: event,
});
Die Falle besteht darin, einen Key zu wählen, der nicht zur Invariante passt. Wenn Sie nach orderId keyen, obwohl die Consumer eine reihenfolgegetreue Verarbeitung pro Kunde benötigen, erhalten Sie einen Parallelismus, den Sie nicht wollten, und eine Reihenfolge, auf die Sie sich nicht verlassen können. Keyen Sie hingegen alles mit einer einzigen Konstante, erhalten Sie eine perfekte Reihenfolge, aber keinerlei Parallelismus.
Zwei weitere Warnhinweise: Eine spätere Änderung der Partitionsanzahl verändert das Mapping vom Key zur Partition. Dadurch können Events einer Entität auf zwei Partitionen aufgeteilt werden und ihre relative Reihenfolge verlieren. Legen Sie die Anzahl daher mit ausreichend Puffer fest. Und wenn ein Consumer eine Partition seriell verarbeitet, blockiert ein einzelnes langsames Event alles dahinter – halten Sie den Aufwand pro Event daher begrenzt.
Schema-Evolution und Versionierung
Ein Event ist ein Vertrag zwischen einem Producer und Consumern, die unabhängig voneinander deployt werden. Der Producer wird aktualisiert, während alte Consumer noch laufen, und ein neuer Consumer liest Events, die vor Monaten geschrieben wurden. Die Struktur des Payloads muss in beide Richtungen funktionieren.
Die Regeln sind dieselben wie bei einer öffentlichen API:
- Hinzufügen, nicht umbenennen oder entfernen. Neue Felder sind optional und werden von Consumern mit Standardwerten belegt.
- Versionieren, wenn sich die Bedeutung ändert. Ein
version-Feld ermöglicht es einem Handler, explizit zu verzweigen, anstatt zu raten. - Niemals einen Feldnamen für ein anderes Konzept wiederverwenden. So beginnt eine schleichende Datenkorruption.
- Alte Events für immer als gültig behandeln. Replay bedeutet, dass der Payload von gestern heute noch parst.
export type OrderPlacedV2 = {
type: "order.placed";
version: 2;
data: {
orderId: string;
totalCents: number;
currency?: string; // added later; v1 events simply lack it
};
};
Bei großen Systemen macht eine Schema Registry daraus ein verwaltbares Problem. Producer registrieren ein Schema – Avro, Protobuf oder JSON Schema – und die Registry weist ihm eine ID zu, erzwingt einen Kompatibilitätsmodus und lehnt Änderungen ab, die bestehende Reader stören würden. Selbst ohne Registry bietet es bereits den größten Nutzen, eine versionierte Schema-Datei im Repo zu führen und Änderungen daran zu reviewen.
Diese Disziplin zahlt sich genau während Incidents aus. Wenn ein Deploy einen Consumer beeinträchtigt, ist die erste Frage, ob der Producer den Payload auf eine Weise geändert hat, der niemand zugestimmt hat.
Contract Tests sind die kostengünstige Variante einer Registry. Führen Sie pro Version eine Fixture-Datei mit echten Events und lassen Sie jeden Consumer alle diese in der CI parsen. Ein Consumer, der bei einer v1-Fixture scheitert, wird in der Produktion scheitern, sobald das erste Mal ein altes Event replayed wird. Dies erfordert nur wenige Dateien und fängt genau die Art von Fehlern ab, die sonst erst Tage später als korrupte Projection auftauchen.
Choreografie und Orchestrierung
Ein mehrstufiger Geschäftsprozess, der auf Events aufbaut, kann auf zwei Arten koordiniert werden – und der Unterschied ist signifikant.
Choreografie bedeutet, dass jeder Service auf Events hört und reagiert, ohne dass es einen zentralen Koordinator gibt. Der Order-Service veröffentlicht order.placed; der Inventory-Service reserviert den Bestand und veröffentlicht inventory.reserved; der Payment-Service bucht den Betrag ab und veröffentlicht payment.captured; der Shipping-Service reagiert darauf. Jeder Service kennt nur die Events, die er konsumiert und produziert. Dieser Ansatz ist lose gekoppelt, erweiterbar und es ist einfach, neue Schritte hinzuzufügen. Es ist jedoch schwierig, den gesamten Prozess zu überblicken, da der Ablauf nur als Summe aller einzelnen Subscriptions existiert.
Orchestrierung bedeutet, dass eine zentrale Komponente – ein Saga-Orchestrator oder Process Manager – jedem Service explizit sagt, was zu tun ist, und den Zustand des Prozesses verfolgt. Der Ablauf ist an einem Ort definiert, was ihn sichtbar, testbar und leichter nachvollziehbar macht. Der Preis dafür ist ein Koordinator, von dem jeder Service abhängig ist und der zu einem Bottleneck sowie einem Single Point of Failure werden kann.
Keiner der beiden Ansätze ist universell richtig:
- Choreografie eignet sich für einfache, weitgehend unabhängige Reaktionen und stabile Schritte.
- Orchestrierung eignet sich für lange Prozesse mit vielen bedingten Verzweigungen, Timeouts und Kompensationsmaßnahmen.
Eine gängige pragmatische Aufteilung besteht darin, den „Happy Path“ zwischen einigen wenigen Services choreografisch zu lösen und einen Orchestrator erst dann einzuführen, wenn ein Prozess so komplex geworden ist, dass er einen benötigt.
Das Signal, dass die Choreografie zu weit gegangen ist, ist eine Änderung, die die gleichzeitige Bearbeitung vieler Services erfordert, oder ein Prozess, den niemand im Team beschreiben kann, ohne fünf Repositories zu öffnen. Wenn das Hinzufügen einer einzigen Geschäftsregel bedeutet, sechs Consumer anzupassen, ist der Ablauf keine Sammlung unabhängiger Reaktionen mehr, sondern ein verteiltes Programm ohne Autor. Das ist der Moment, es in einen Orchestrator zu überführen.
Sagas und Kompensationen
In einem Monolithen kann eine mehrstufige Operation in eine Datenbanktransaktion eingeschlossen und bei einem Fehler zurückgerollt werden. Über verschiedene Services hinweg gibt es keine gemeinsame Transaktion, sodass ein verteilter Prozess nicht einfach abgebrochen werden kann. Wenn die Zahlung erfolgreich war, aber der Versand fehlschlug, kann die Zahlung nicht einfach mit einem ROLLBACK rückgängig gemacht werden.
Das Saga-Pattern löst dieses Problem. Eine Saga ist eine Sequenz lokaler Transaktionen, wobei jede eine Event veröffentlicht, die den nächsten Schritt auslöst. Wenn ein Schritt fehlschlägt, führt die Saga kompensierende Aktionen für die Schritte aus, die bereits erfolgreich waren: die Zahlung zurückerstatten, das reservierte Inventar freigeben, die Bestellung als storniert markieren. Kompensation ist kein Rollback – es ist eine neue Geschäftsaktion, die die Wirkung rückgängig macht, und sie ist selbst ein Event, das idempotent sein muss.
// Forward path
// order.placed -> inventory.reserved -> payment.captured -> order.confirmed
// If payment fails, compensate the steps that already ran.
await events.publish("payment.failed", { orderId, reason });
// inventory service listens and releases the reservation
Zwei Designregeln sorgen dafür, dass Sagas handhabbar bleiben. Erstens muss jeder Schritt idempotent sein, da ein Retry ihn erneut ausführen kann. Zweitens benötigt jeder Schritt eine im Voraus definierte kompensierende Aktion – wenn ein Schritt nicht rückgängig gemacht werden kann, kann die Saga danach nicht sicher fehlschlagen; dieser Schritt muss also ganz am Ende stehen oder ein anderes Design erhalten. Sagas machen zudem Zwischenzustände sichtbar, daher sollte die UI “Bestand wird reserviert” und “Zahlung ausstehend” anzeigen, anstatt vorzugeben, dass die Operation atomar erfolgt.
Eine Saga benötigt einen eigenen Zustand. Entweder speichert der Orchestrator den aktuellen Schritt in einer Tabelle, oder jeder Service verfolgt die Events, die er gesehen hat. Dieser Zustand ermöglicht es, einen Prozess nach einem Absturz fortzusetzen, einen Schritt, der nie geantwortet hat, per Timeout zu beenden und zu wissen, welche Kompensationen noch ausstehen. Eine Saga ohne persistierten Zustand ist eine Sequenz von Nachrichten, die irgendwann in einem Zustand stecken bleibt, den niemand mehr rekonstruieren kann.
Timeouts verdienen besondere Aufmerksamkeit, da ein Schritt, der nie antwortet, der häufigste Fehlerfall ist. Wenn die Zahlung innerhalb eines Zeitfensters weder erfolgreich ist noch fehlschlägt, muss die Saga entscheiden: Retry, Kompensation oder die Bestellung für eine manuelle Prüfung parken. Wenn dies unentschieden bleibt, verharrt die Bestellung ewig im Nirgendwo und blockiert Inventar, das niemals freigegeben wird.
Dead-letter queues und Replay
Ein Event, das ein Consumer nicht verarbeiten kann, wird bei jedem erneuten Versuch scheitern: ein fehlerhafter Payload, ein Bug im Handler oder eine referenzierte Zeile, die nicht existiert. Ein endloser Retry-Zyklus belegt einen Consumer-Slot und blockiert alles Folgende in der Partition oder Queue.
Die Lösung ist eine dead-letter queue (DLQ). Nach einer konfigurierten Anzahl von Versuchen verschiebt der Broker das Event – inklusive Payload, Headern, Versuchsanzahl und dem letzten Fehler – in eine separate Queue, die von keinem Consumer gelesen wird. Der reguläre Traffic fließt ungehindert weiter, und ein Operator kann das fehlgeschlagene Event untersuchen, die Ursache beheben und es per replay erneut einspielen.
Replay ist die unterschätzte Superkraft eventgesteuerter Systeme. Da Events persistent sind, können Sie die Historie nach der Behebung eines Bugs erneut verarbeiten: Bauen Sie eine falsch berechnete Projection neu auf, füllen Sie einen nachträglich hinzugefügten Service mit Altdaten (Backfill) oder lassen Sie die Events eines ganzen Tages gegen eine neue Logik laufen. Die Voraussetzung ist, dass Consumer idempotent sind, da Replay ihnen Events liefert, die sie möglicherweise bereits verarbeitet haben.
Betrachten Sie die DLQ als operative Schnittstelle, nicht als Friedhof. Richten Sie Alarme für die Queue-Tiefe ein, integrieren Sie sie in Ihr Dashboard und bauen Sie einen Replay-Pfad, bevor Sie ihn mitten in der Nacht um 2 Uhr benötigen. Eine dead-letter queue, die niemand beobachtet, ist der perfekte Ort für versteckte Bugs.
Replay erfordert zudem eine Strategie für die Retention. Sie können nur Events erneut verarbeiten, die der Broker noch gespeichert hat; das Retention-Fenster definiert also, wie weit ein Fix zurückreichen kann. Ein Topic mit einer Aufbewahrungsfrist von sieben Tagen kann keine Projection wiederherstellen, wenn ein Bug zwei Wochen lang lief. Legen Sie die Retention basierend auf der tatsächlich gewünschten Recovery-Zeit fest und bedenken Sie, dass jedes Byte durch die Replikation und den Speicher jedes einzelnen Consumers vervielfacht wird.
Den Event-Flow sichtbar machen
Der schwierigste Teil von eventgesteuerten Systemen ist nicht deren Aufbau, sondern das Verständnis dessen, was passiert ist, nachdem etwas schiefgelaufen ist. Eine einzige Benutzeraktion kann ein Dutzend Events über sechs Services hinweg auslösen, und der Fehler liegt möglicherweise im dritten Consumer des fünften Events.
Drei Praktiken machen den Flow beobachtbar:
- Correlation ID. Generieren Sie eine ID am Edge, fügen Sie diese jedem Event und jeder Log-Zeile hinzu und propagieren Sie sie über jeden Hop hinweg. Ein einziger
greprekonstruiert dann den gesamten Flow. - Tracing. OpenTelemetry und ähnliche Tools modellieren ein Event als Span, der mit dem Event verknüpft ist, das es ausgelöst hat. Dadurch wird der Flow zu einem Graph, den man lesen kann.
- Consumer Lag und DLQ-Tiefe. Diese beiden Metriken erkennen die meisten Probleme, bevor ein Benutzer es tut: ein Consumer, der ins Hintertreffen gerät, oder ein Handler, der begonnen hat, Fehler zu produzieren.
await events.publish("order.placed", {
...event,
correlationId: req.id, // set once, carried everywhere
});
Loggen Sie die Event-ID und den Typ sowohl auf der Publish- als auch auf der Consume-Seite. Ohne dies bedeutet Debugging, Zeitstempel über Services hinweg zu korrelieren und zu hoffen, dass die Uhren synchron laufen.
Ein nützliches Dashboard für einen Event-Flow hat drei Zeilen: die Publish-Rate pro Event-Typ, den Consumer Lag pro Gruppe und die DLQ-Tiefe pro Consumer. Eine Publish-Rate, die auf Null sinkt, bedeutet, dass ein Producer gestoppt hat; ein steigender Lag bedeutet, dass ein Consumer nicht mithalten kann; eine wachsende DLQ-Tiefe bedeutet, dass ein Handler defekt ist. Zusammen erklären diese drei Signale die meisten Incidents, noch bevor jemand ein Log öffnet.
Thin Events, Fat Events und der Payload-Contract
Eine wiederkehrende Design-Frage ist, wie viele Daten ein Event transportieren sollte. Ein thin Event enthält nur eine Kennung und einen Typ — order.placed mit einem orderId. Ein fat (oder angereichertes) Event transportiert einen vollständigen Snapshot: Einzelposten, Summen, die Lieferadresse, wie sie zum Zeitpunkt der Bestellung vorlag.
Fat Events machen Consumer einfacher und robuster. Ein Suchindex-Indexer, der die gesamte Bestellung erhält, muss nicht zurück auf den Orders-Service zugreifen, wodurch eine Runtime-Abhängigkeit und ein potenzieller Failure Mode entfallen. Gleichzeitig erweitern sie jedoch den Contract: Jedes Feld ist nun etwas, von dem ein Consumer abhängen kann. Dadurch wird es schwieriger, die Struktur zu ändern, und das Event könnte Daten transportieren, die ein bestimmter Consumer nicht sehen darf.
Thin Events halten den Contract minimal und den Payload klein, aber jeder Consumer muss den aktuellen Zustand abrufen. Dies führt die Kopplung wieder ein und kann dazu führen, dass Daten gelesen werden, die sich seitdem geändert haben. Das Event ist dann kein vollständiger Fakt mehr, sondern wird zu einem Pointer.
Ein praktikabler Standard ist es, die Felder aufzunehmen, die den Fakt definieren und sicher geteilt werden können, während alles andere per ID referenziert wird. order.placed sollte die Order-ID, die Customer-ID und die Gesamtsumme enthalten, da diese den Fakt ausmachen. Es sollte nicht das gesamte Profil des Kunden einbetten. Der Test ist einfach: Kann ein Consumer das Event für den Zweck, den es ankündigt, verstehen, ohne zu einer Kopie Ihrer Datenbank zu werden?
Egal wofür Sie sich entscheiden: Frieren Sie die Werte ein, die sich nicht ändern dürfen. Wenn beim Checkout ein Preis angegeben wurde, sollte das Event diesen Preis transportieren, selbst wenn der Instinkt für Thin Events besagt, ihn später nachzuschlagen — denn später könnte der Preis anders sein, und das Event würde dann einen Fakt beschreiben, der so nie stattgefunden hat.
Die Wahl des Brokers ohne Framework-Krieg
Die Wahl des Brokers ist die am wenigsten spannende Entscheidung, aber diejenige, über die Teams am meisten streiten. Drei Kategorien decken fast jeden Anwendungsfall ab.
- Log-basierte Broker wie Kafka und NATS JetStream speichern Events für ein bestimmtes Zeitfenster und ermöglichen es jeder Consumer-Gruppe, ihre eigene Position zu verfolgen. Wählen Sie diese, wenn Replay, hoher Durchsatz oder viele unabhängige Leser Anforderungen sind.
- Exchange-basierte Broker wie RabbitMQ routen Nachrichten mithilfe von Routing-Keys an Queues, inklusive Bestätigungen pro Nachricht, Prioritäten und Dead-Letter-Exchanges. Wählen Sie diese, wenn Routing und Aufgabenverteilung wichtiger sind als die Historie.
- Cloud-Queues wie SQS und Pub/Sub sind vollständig verwaltet und effektiv unbegrenzt skalierbar, allerdings auf Kosten von Low-Level-APIs und weniger Kontrolle über Reihenfolge und Scheduling.
Die ehrlichste Empfehlung lautet: Beginnen Sie mit dem, was auf Ihrer Plattform bereits läuft und was Ihr Team bereits versteht. Ein korrekt implementiertes System auf einem vertrauten Broker schlägt ein theoretisch perfektes System auf einem Broker, den niemand bedienen kann. Migrieren Sie erst, wenn eine spezifische Einschränkung – sei es Replay, Routing oder Durchsatz – tatsächlich zum Problem wird, und nicht, weil in einem Conference-Talk ein anderes Tool bevorzugt wurde.
Egal wofür Sie sich entscheiden: Verstecken Sie den Broker in Ihrem eigenen Code hinter einem dünnen Publisher-Interface. publish(topic, event) ist eine stabile Nahtstelle; ein Vendor-SDK hingegen nicht. Das hält die Entscheidung für den Broker reversibel und ermöglicht es Tests, Nachrichten an einen In-Memory-Collector statt an einen echten Cluster zu senden.
Einen Event-Flow testen
Events sind asynchron, was Tests oft mühsam macht, solange man die einzelnen Teile nicht trennt.
Testen Sie den Producer, indem Sie die Outbox und nicht den Broker prüfen. Nach dem Aufruf des Service müssen sowohl die State-Zeile als auch die Outbox-Zeile existieren und der Payload muss dem Schema entsprechen. Ein Broker ist hierfür nicht erforderlich.
Testen Sie den Consumer als reine Funktion eines Events. Übergeben Sie ihm einen Payload und prüfen Sie den resultierenden State. Übergeben Sie ihm anschließend denselben Payload ein zweites Mal und stellen Sie sicher, dass der zweite Durchlauf ein No-Op ist – dies ist der Idempotenz-Test, mit dem jene Fehlerklasse abgefangen wird, die in der Produktion nur bei einer Redelivery auftritt.
Testen Sie das Wiring mit einem echten Broker in einem Container: Veröffentlichen Sie ein Event, warten Sie, bis der Consumer es verarbeitet hat, und prüfen Sie den Endzustand. Verwenden Sie pro Testlauf eine eindeutige Group ID oder Queue, damit committed Offsets niemals zwischen den Durchläufen durchsickern, und prüfen Sie den Empfang von Records anstatt das Timing.
test("reprocessing an event is a no-op", async () => {
await onOrderPlaced(event);
await onOrderPlaced(event); // redelivery
const { rows } = await pool.query(
"SELECT count(*)::int AS n FROM invoices WHERE order_id = $1",
[event.data.orderId],
);
expect(rows[0].n).toBe(1);
});
Halten Sie die asynchrone Assertion deterministisch, indem Sie mittels Polling mit einem Timeout auf den erwarteten Zustand warten, anstatt ein festes Intervall mit sleep zu nutzen. Ein Test, der nur deshalb besteht, weil er lange genug gewartet hat, wird auf einer langsameren Maschine zu einem Flaky Test.
Wann event-driven glänzt und wann es schmerzt
Event-driven Architecture ist ein Kompromiss, und es lohnt sich, explizit zu machen, auf welcher Seite man steht.
Sie glänzt, wenn man eine Entkopplung zwischen Teams benötigt, die unabhängig voneinander deployen, wenn ein Fan-out an viele Reader, das Replay von Historien, ein dauerhafter Audit-Trail oder ein natürlicher Feed für Analytics und Suche erforderlich ist. Sie passt zu Systemen, in denen eine nachgelagerte Reaktion geringfügig verzögert erfolgen kann und bei denen das Hinzufügen eines neuen Consumers keine Änderung am Producer erfordern sollte.
Sie schmerzt, wenn die Domain klein und CRUD-lastig ist, wenn eine Operation sofort und stark konsistent sein muss oder wenn das Team zu klein ist, um einen Broker zu betreiben und über Eventual Consistency nachzudenken. In diesen Fällen ist ein gut strukturierter Monolith mit klaren Modulen und einer einzigen Datenbank einfacher, schneller aufzubauen und leichter zu debuggen. Events können immer später hinzugefügt werden, und ein modularer Monolith ist ein wesentlich besserer Startpunkt als ein verteiltes System, das niemand mehr nachverfolgen kann.
Eine vernünftige Regel: Führen Sie keinen Broker ein, bis Sie das spezifische Problem benennen können, das er löst. „Microservices nutzen Events“ ist keine Problemstellung. Schreiben Sie auf, was Sie sich erhoffen – einen neuen Consumer ohne Eingriff in den Producer, ein Replay nach einem Bug, einen Audit-Trail – und prüfen Sie später, ob Sie dies erreicht haben. Wenn die ehrliche Antwort lautet: „Wir wollten modern wirken“, dann ist der Broker ein Kostenfaktor ohne Gegenwert.
Falls Sie sich dennoch dafür entscheiden, führen Sie es inkrementell ein. Beginnen Sie mit einem einzigen Event, das ein reales Problem löst, lassen Sie es eine Zeit lang in der Produktion laufen und lernen Sie, wie Lag, Duplikate und Schema-Änderungen in Ihrem Team und Ihrer Infrastruktur wirken, bevor Sie Events zum Rückgrat des Systems machen.
Best Practices
- Benennen Sie Events in der Vergangenheitsform und weisen Sie sie dem Producer zu; benennen Sie Commands separat.
- Schreiben Sie das Event in einer Outbox innerhalb derselben Transaction wie die Zustandsänderung.
- Veröffentlichen Sie über einen Relay oder CDC-Connector, niemals direkt aus einem Request-Handler.
- Machen Sie jeden Consumer idempotent, indem Sie einen Dedupe-Key verwenden, der aus der Event-ID abgeleitet wird.
- Wählen Sie einen Partition-Key, der der Reihenfolge entspricht, die jeder Consumer tatsächlich benötigt.
- Versionieren Sie Event-Payloads und entwickeln Sie diese additiv weiter; verwenden Sie ein Feld niemals für einen anderen Zweck.
- Halten Sie Consumer klein und auf einen einzigen Zweck beschränkt; ein Consumer pro Projection oder Reaktion.
- Definieren Sie kompensierende Aktionen für jeden Schritt, bevor Sie eine Saga implementieren.
- Begrenzen Sie Retries, leiten Sie erschöpfte Events an eine Dead-Letter Queue weiter und bauen Sie einen Replay-Pfad.
- Propagieren Sie eine Correlation-ID in jedem Event und loggen Sie diese auf beiden Seiten.
- Überwachen Sie den Consumer-Lag sowie die DLQ-Tiefe und richten Sie Alerts für Trends ein.
- Starten Sie mit einem modularen Monolithen und führen Sie Events erst ein, wenn Entkopplung oder Replay eine echte Notwendigkeit darstellen.
Häufige Fehler
- Befehle als Events bezeichnen und so versehentlich einen Distributed Procedure Call implementieren.
- Event-driven, Event Sourcing und CQRS vermischen und versuchen, alle drei gleichzeitig einzuführen.
- Direkt aus einem Request Handler heraus publizieren und damit in das Dual-Write-Problem laufen.
- Von einer Exactly-Once-Delivery ausgehen und beim ersten Redelivery doppelt abbuchen.
- Events zufällig keyen und dann eine Reihenfolge pro Entität erwarten.
- Ein Feld im Payload umbenennen oder entfernen und damit Consumer einer alten Version zum Absturz bringen.
- Choreography aufbauen, ohne eine Möglichkeit zu haben, den End-to-End-Prozess zu überblicken.
- Ein Poison Event endlos retryen und so die dahinterliegende Partition blockieren.
- Die Dead-Letter Queue komplett ignorieren.
- Einen Broker für eine einfache CRUD-App einführen und die Komplexität ohne Mehrwert in Kauf nehmen.
Wie geht es weiter?
Der Transportmechanismus unter einem event-gesteuerten System ist in der Regel ein Log oder ein Broker. Apache Kafka deckt dabei das Modell des replaybaren Logs ab, während RabbitMQ den Fokus auf Routing-first Messaging legt. Da Events die Art und Weise sind, wie Services in einem verteilten System kommunizieren, erklärt der Guide zu Microservices, wie die Service-Grenzen überhaupt definiert werden. Falls das alles für Ihr Problem zu komplex erscheint, bietet der Guide zum Modular Monolith den ehrlichen Gegenpol: Die meisten Systeme sollten eine einzige deploybare Einheit bleiben, bis ein wirklich triftiger Grund für eine Aufteilung erscheint.