Eine typische Backend-Situation: Eine Bestellung wird in der Datenbank gespeichert, danach soll ein Event „OrderCreated“ an einen Message Broker gehen, damit Fulfillment, E-Mail und Analytics reagieren können. Passiert zwischen Datenbank-Commit und dem Publish ein Fehler (Timeout, Broker down, Pod restart), entsteht eine Lücke. Genau hier setzt das Outbox Pattern an: Der Service schreibt Geschäftsänderung und „zu sendendes Event“ atomar in dieselbe Datenbanktransaktion, und ein separater Prozess veröffentlicht die Events asynchron.
Das Pattern ist besonders hilfreich, wenn Services nicht einfach „noch einmal“ publizieren dürfen oder wenn es wichtig ist, dass Events das System zuverlässig verlassen. Es ersetzt keine saubere Event-Verarbeitung auf der Konsumentenseite, schafft aber eine belastbare Grundlage für konsistente Integration.
Warum Datenbank-Commit und Publish auseinanderlaufen
Der Klassiker: Commit erfolgreich, Publish fehlgeschlagen
Viele Codebases machen erst ein INSERT/UPDATE, danach ein Publish. Der Datenbank-Commit ist final, das Publish hängt an Netzwerk und Broker. Fällt der Prozess genau danach um, fehlt das Event dauerhaft. Ein Retry im Application Code hilft nur, wenn der Prozess den Zustand überhaupt noch kennt. Ohne „nachholbaren“ Persistenzpunkt ist das nicht gegeben.
Die umgekehrte Variante: Publish erfolgreich, DB-Rollback
Wird zuerst publiziert und danach persistiert, kann ein Rollback passieren (Constraint verletzt, Deadlock, Timeout). Dann existiert ein Event, das auf einen Zustand zeigt, den es nie gab. Das ist oft schwerer zu reparieren als ein fehlendes Event, weil Konsumenten schon Aktionen angestoßen haben.
Warum verteilte Transaktionen selten die Antwort sind
Zwei-Phasen-Commit über Datenbank und Broker klingt verlockend, ist in der Praxis aber teuer und fragil: zusätzliche Latenz, komplexe Failure-Modes, unterschiedliche Unterstützung je Technologie. Das Outbox-Pattern bleibt innerhalb einer lokalen Datenbanktransaktion und verschiebt „Senden“ in einen robusten, wiederholbaren Hintergrundprozess.
Das Grundprinzip: Event zuerst als Datensatz, dann als Nachricht
Outbox-Tabelle als „Sendewarteschlange“
Zusätzlich zur Geschäftslogik wird eine Tabelle (oder ein Collection/Space) geführt, die zu sendende Events enthält. Der wichtigste Punkt: Geschäftsänderung und Outbox-Eintrag passieren in atomaren Datenbanktransaktionen. Damit ist garantiert: Entweder existieren beide, oder keines.
Publisher/Relay veröffentlicht und bestätigt
Ein Worker liest die Outbox-Einträge, publiziert sie und markiert sie danach als „sent“ (oder löscht sie). Der Worker darf dabei abstürzen oder neu starten, ohne dass Events verloren gehen: Nicht bestätigte Einträge bleiben in der Tabelle und werden später erneut verarbeitet.
Event-Inhalt: Payload vs. Event-Referenz
Es gibt zwei gängige Varianten:
-
Event Payload in der Outbox: Das Event enthält alle Daten, die Konsumenten brauchen. Vorteil: Konsumenten sind entkoppelt, keine zusätzlichen Reads. Nachteil: größere Outbox, sorgfältiges Schema- und Versionsmanagement.
-
Nur eine Referenz (z. B. Aggregate-ID) in der Outbox: Konsumenten oder ein API-Call holen Details. Vorteil: kleine Events. Nachteil: stärkerer Laufzeit-Kopplung und mehr Last auf dem Origin-Service.
Schema-Design, das im Betrieb nicht weh tut
Minimal sinnvolle Spalten
Ein praxistaugliches Schema enthält typischerweise:
-
id (UUID/ULID oder Sequence): eindeutige Event-ID für Deduplication.
-
aggregate_type, aggregate_id: Kontext, z. B. „order“, „123“.
-
event_type: z. B. „OrderCreated“.
-
payload (JSON/Text) oder reference: Event-Daten.
-
status: NEW, SENDING, SENT, FAILED (optional).
-
created_at, sent_at: Beobachtbarkeit und Retention.
-
attempt_count, last_error: Diagnose bei wiederholten Fehlern.
Indexierung und „Hot Spots“ vermeiden
Für Polling-Worker sind ein Index auf (status, created_at) und ggf. ein Covering Index hilfreich. Bei sehr hohem Durchsatz wird oft nach „älteste zuerst“ abgearbeitet; das kann zu Hot Spots führen. Abhilfe schaffen Sharding nach Hash(aggregate_id) oder Partitionierung nach Zeit (z. B. täglich), abhängig von Datenbank und Retention-Strategie.
Retention: Outbox ist kein Archiv
Gesendete Events sollten zeitnah gelöscht oder in eine History-Tabelle verschoben werden. Sonst wächst die Outbox und Polling wird teurer. Retention ist eine Betriebsentscheidung: Genug Historie für Debugging, aber nicht so viel, dass Queries leiden.
Publishing-Strategien: Polling oder Change Data Capture
Polling: einfach, robust, überall
Beim Polling fragt ein Worker regelmäßig nach NEW-Einträgen. Vorteile: funktioniert mit praktisch jeder Datenbank, überschaubar in der Implementierung, klarer Backpressure-Mechanismus (Workeranzahl). Nachteile: zusätzliche Last durch Polling und potenziell höhere Latenz (abhängig vom Intervall).
CDC: niedrige Latenz, mehr Infrastruktur
Bei Change Data Capture (CDC) wird das DB-Transaktionslog gelesen, um Änderungen (inkl. Outbox INSERTs) zu streamen. Das reduziert Polling-Last und kann Latenz minimieren, erhöht aber die Komplexität: Betrieb von Connectoren, Rechte auf Log-Streams, Re-Processing, Schema-Evolution. CDC kann stark sein, ist aber nicht automatisch „besser“ als Polling.
Genau-einmal vs. mindestens-einmal: realistische Ziele setzen
Doppelte Publishes sind normal und beherrschbar
Auch mit Outbox kann ein Event zweimal publiziert werden, z. B. wenn der Worker nach erfolgreichem Publish abstürzt, bevor „SENT“ geschrieben wird. Das System muss deshalb mindestens-einmal tolerieren. Dafür braucht es Idempotenz bei Konsumenten oder im Broker (Dedup), typischerweise über eine Event-ID.
Deduplication in der Praxis
Ein Konsument kann eine Tabelle „processed_events“ führen (event_id als Unique Key). Beim Empfang wird zuerst versucht, event_id einzutragen; klappt das nicht wegen Unique Violation, wird das Event ignoriert. Alternativ kann die Dedup in eine vorhandene Business-Tabelle integriert werden, wenn ein natürlicher Schlüssel existiert.
Fehlerfälle, die in echten Systemen auftreten
Broker down: Retry mit Backoff und Dead-Letter
Wenn der Broker nicht erreichbar ist, darf der Worker nicht „tight loop“ pollen und veröffentlichen. Sinnvoll sind exponentieller Backoff pro Event oder pro Worker, plus eine Obergrenze für Versuche. Nach X Fehlschlägen kann der Status auf FAILED wechseln, damit ein Operator gezielt reagieren kann.
Poison Messages: valide Events, die trotzdem scheitern
Ein Event kann syntaktisch korrekt sein und dennoch beim Publish oder beim Konsumenten scheitern (z. B. zu groß, ungültiges Encoding, Schema-Konflikt). Outbox hilft hier nur, wenn Fehler sauber gespeichert werden (last_error) und ein Prozess existiert, um solche Einträge zu untersuchen, zu korrigieren oder manuell neu zu senden.
Ordering: pro Aggregate stabil, global oft unnötig
Viele Domänen brauchen nur eine Reihenfolge pro Aggregate (z. B. pro Bestellung), nicht global. Das beeinflusst Topic-Partitionierung und Outbox-Selektionslogik. Ein verbreitetes Muster ist ein „aggregate_version“-Feld im Event, das Konsumenten nutzen, um veraltete Zustände zu erkennen.
Konkretes Vorgehen für ein sauberes Setup im Team
Umsetzbare Schritte für Design, Code und Betrieb
-
Outbox-Schema definieren (id, event_type, payload/reference, status, timestamps) und Migrationspfad festlegen.
-
Im Write-Use-Case eine Transaktion nutzen: Business-Write + Outbox-Insert, nur bei Erfolg committen.
-
Publisher-Worker implementieren: Batch lesen, publish, Status aktualisieren; Crash-Sicherheit durch erneutes Lesen nicht bestätigter Einträge.
-
Retry-Policy festlegen: Backoff, Max Attempts, FAILED-Handling, Alerting bei ansteigender Queue.
-
Deduplication-Strategie für Konsumenten festlegen (event_id als Unique Key) und dokumentieren.
-
Monitoring definieren: Anzahl NEW/FAILED, Publish-Latenz (created_at bis sent_at), Fehlerquoten, Worker-Throughput.
Einordnung zu ähnlichen Mustern im Backend
Outbox und zuverlässige Event-Verarbeitung
Das Pattern ergänzt andere Stabilitätsbausteine: Wenn Events per HTTP verarbeitet werden, sind Retries, Signaturen und Queueing auf der Empfangsseite relevant. Dazu passt der Ansatz aus Webhooks zuverlässig verarbeiten. Für APIs, bei denen Clients wiederholen (z. B. bei Timeouts), bleibt das Thema idempotente APIs zentral.
Rolle im Service-Layer und in der Architektur
In vielen Codebases gehört das Erstellen des Outbox-Eintrags in denselben Application-Service, der auch die Business-Entscheidung trifft. Eine klare Schichtung hilft, damit Outbox nicht quer durch Controller, Repositories und Domain-Objekte „durchsticht“. Passend dazu: Service-Layer im Backend.
Typische Integrationsdetails, die gern übersehen werden
Batching, Locking und konkurrierende Worker
Mehrere Worker erhöhen Durchsatz, bergen aber das Risiko doppelter Verarbeitung. Üblich ist ein Claiming-Mechanismus: Ein Worker markiert eine Menge Events als SENDING (z. B. per UPDATE mit WHERE status=NEW und LIMIT), danach publiziert er genau diese IDs. Fällt er aus, können Events nach einem Timeout wieder auf NEW gesetzt werden.
Transaktionsgrenzen sauber halten
Der Publish selbst gehört nicht in die Datenbanktransaktion des Business-Writes. Sonst verlängert sich die Lock-Zeit und der Broker wird Teil des kritischen Pfads. Die Entkopplung ist der Kernnutzen: DB-Commit schnell und lokal, Publish wiederholbar und separat.
Payload-Versionierung und Schema-Evolution
Events sind Schnittstellen. Ein versioniertes Feld (z. B. schema_version) und additive Änderungen sind praxistauglich. Breaking Changes sollten über neue event_type-Namen oder parallele Topics laufen, damit alte Konsumenten nicht spontan brechen.
