Das nimmst du mit
- Crash-Fenster benennen
- Idempotency Keys nutzen
- Duplikate tolerieren
- Recovery testen
Kapitelkompass
Die Outbox verbindet lokale Persistenz und spätere Veröffentlichung, ohne eine verteilte Transaktion vorzutäuschen.
Der Prozess stirbt nach Datenbank-Commit, aber vor der Bestätigung des Brokers.
At-least-once plus idempotente Verarbeitung ist meist robuster als vermeintliches Exactly-once.
Eine Outbox ohne deduplizierenden Consumer verschiebt das Duplikatproblem nur weiter.
Thema: Outbox, Idempotenz, Retry und Crash-Fälle Deep Dive
Dieser Kapitel vertieft Outbox, Idempotenz, Retry und Crash-Fälle als eigenes Lernkapitel mit echtem Java-21-Code-Lab.
Transactional Outbox ist kein dekoratives Integrationsmuster, sondern eine Antwort auf ein sehr konkretes Produktionsproblem: Ein Use Case schreibt fachlichen Zustand in eine Datenbank und muss danach ein Ereignis an andere Systeme liefern. Wenn diese beiden Aktionen nicht gemeinsam konsistent behandelt werden, entstehen Lücken zwischen Order, Billing, Reporting und Audit.
Das Thema ist tiefer als “wir speichern Events in einer Tabelle”. Entscheidend sind die Zwischenfälle: Der Prozess stürzt nach dem Commit ab. Der Broker nimmt ein Event an, aber die Anwendung kann den Status nicht mehr aktualisieren. Der Consumer erhält dieselbe Nachricht zweimal. Retry erzeugt eine Lastwelle. Eine vermeintlich erledigte Nachricht landet nach Stunden wieder im System. Genau diese Fälle werden in diesem Kapitel als Szenario, Code und Test behandelt.
Deep-Dive-Regel: Ein Event-Flow ist erst dann robust verstanden, wenn nicht nur der Happy Path funktioniert, sondern jeder Crash-Punkt einen erwarteten, testbaren Folgezustand hat.
Die naive Lösung sieht in Reviews oft harmlos aus: Order speichern,
danach broker.publish(orderAcceptedEvent) aufrufen. Das
Problem liegt nicht im Codeformat, sondern in der fehlenden gemeinsamen
Transaktionsgrenze. Die Datenbank und der Broker können nicht als eine
atomare Operation behandelt werden, solange keine verteilte Transaktion
eingesetzt wird. Verteilte Transaktionen sind in modernen
Cloud-/Container-Landschaften oft unerwünscht, teuer oder gar nicht
sauber verfügbar.
Eine schlechte Lösung mischt fachliche Konsistenz und Infrastrukturverfügbarkeit in derselben Methode. Wenn der Broker kurz nicht erreichbar ist, weiß der Use Case plötzlich nicht mehr, ob er die Bestellung fachlich akzeptieren oder ablehnen soll. Wenn das Event schon gesendet wurde und danach die DB-Transaktion fehlschlägt, sehen abhängige Systeme eine Bestellung, die es in der Datenbank nicht gibt. Wenn die DB committet, aber der Prozess vor dem Senden stirbt, fehlt das Event vollständig.
// Schlechte Skizze: zwei Wahrheiten ohne gemeinsame Transaktionsgrenze.
@Transactional
public void placeOrder(PlaceOrderCommand command) {
Order order = Order.accept(command.orderId(), command.total(), clock.instant());
orderRepository.save(order);
// Gefährlich: Broker-Aufruf ist nicht Teil derselben DB-Transaktion.
broker.publish(new OrderAcceptedEvent(order.id(), order.total()));
}
Diese Methode ist nicht deshalb schlecht, weil sie kurz ist. Sie ist schlecht, weil sie keinen verlässlichen Wiederanlaufpunkt besitzt. Nach einem Crash kann das System nicht aus der Datenbank rekonstruieren, ob noch etwas zu senden ist.
Die Outbox-Lösung verschiebt den Broker-Aufruf aus der Use-Case-Transaktion. Der Application Service schreibt den fachlichen Zustand und zusätzlich eine Outbox-Zeile. Diese Zeile ist keine technische Nebensache, sondern eine persistente fachliche Absicht: “Dieses Ereignis muss veröffentlicht werden”.
Der wichtige Schnitt ist: Der Use Case endet nach dem DB-Commit. Der Publisher ist ein separater Prozess. Dadurch wird der Broker-Ausfall nicht zur unmittelbaren Frage, ob eine Bestellung fachlich angenommen wurde. Die Bestellung ist angenommen, wenn die DB-Transaktion mit Order und Outbox erfolgreich committed wurde. Der Versand wird nachgeholt.
package com.example.outboxdeepdive.order;
import com.example.outboxdeepdive.outbox.*;
import com.example.outboxdeepdive.shared.*;
import java.time.Instant;
// Pattern: Application Service - definiert Use-Case- und Transaktionsgrenze.
public final class PlaceOrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
public PlaceOrderService(OrderRepository orders, OutboxRepository outbox) {
this.orders = orders;
this.outbox = outbox;
}
public OrderId placeOrder(String id, Money total, Instant now) {
Order order = Order.accept(OrderId.of(id), total, now);
// In einer echten Datenbank waere dies eine Transaktion:
// 1) Order speichern
// 2) OutboxMessage speichern
// 3) Commit
orders.save(order);
outbox.save(OutboxMessage.pending("Order", order.id().value(), "OrderAccepted", 1, payload(order), now));
return order.id();
}
private String payload(Order order) {
return "{"orderId":"" + order.id().value() + "","amount":"" + order.total().amount() + "","currency":"" + order.total().currency() + ""}";
}
}
Der Kommentar im Code ist Absicht: In diesem JDK-only-Lab gibt es
keine echte Datenbanktransaktion. Die Struktur bildet aber die echte
Zielarchitektur ab. In Spring, Jakarta EE oder Quarkus läge hier eine
echte transaktionale Grenze um orders.save(order) und
outbox.save(message).
Eine Outbox-Tabelle mit nur id, payload und
sent ist für Lernbeispiele ausreichend, aber für
produktionsnahe Systeme zu schwach. Man braucht Zustand, Versuchszahl,
Fehlermeldung, nächste Ausführungszeit und oft auch Event-Version,
Aggregate-ID und Event-Typ. Sonst wird Betrieb schwierig: Man kann nicht
gut erkennen, ob Nachrichten hängen, ob Retry eskaliert oder ob ein
bestimmtes Aggregat wiederholt Probleme macht.
package com.example.outboxdeepdive.outbox;
import java.time.Instant;
import java.util.*;
// Pattern: Outbox Message - persistente Publikationsabsicht mit explizitem Zustand.
public final class OutboxMessage {
private final UUID id;
private final String aggregateType;
private final String aggregateId;
private final String eventType;
private final int eventVersion;
private final String payload;
private final Instant createdAt;
private OutboxStatus status;
private int attempts;
private Instant nextAttemptAt;
private String lastError;
private OutboxMessage(UUID id, String aggregateType, String aggregateId, String eventType, int eventVersion, String payload, Instant createdAt) {
this.id = Objects.requireNonNull(id);
this.aggregateType = require(aggregateType, "aggregateType");
this.aggregateId = require(aggregateId, "aggregateId");
this.eventType = require(eventType, "eventType");
this.eventVersion = eventVersion;
this.payload = require(payload, "payload");
this.createdAt = Objects.requireNonNull(createdAt);
this.status = OutboxStatus.PENDING;
this.nextAttemptAt = createdAt;
}
public static OutboxMessage pending(String aggregateType, String aggregateId, String eventType, int version, String payload, Instant now) {
return new OutboxMessage(UUID.randomUUID(), aggregateType, aggregateId, eventType, version, payload, now);
}
private static String require(String s, String n) {
if (s == null || s.isBlank()) throw new IllegalArgumentException(n + " required");
return s;
}
public UUID id() {
return id;
}
public String aggregateType() {
return aggregateType;
}
public String aggregateId() {
return aggregateId;
}
public String eventType() {
return eventType;
}
public int eventVersion() {
return eventVersion;
}
public String payload() {
return payload;
}
public Instant createdAt() {
return createdAt;
}
public OutboxStatus status() {
return status;
}
public int attempts() {
return attempts;
}
public Instant nextAttemptAt() {
return nextAttemptAt;
}
public String lastError() {
return lastError;
}
public boolean dueAt(Instant now) {
return (status == OutboxStatus.PENDING || status == OutboxStatus.FAILED) && !nextAttemptAt.isAfter(now);
}
public void claim() {
if (status != OutboxStatus.PENDING && status != OutboxStatus.FAILED) throw new IllegalStateException("cannot claim " + status);
status = OutboxStatus.IN_FLIGHT;
}
public void markSent() {
status = OutboxStatus.SENT;
lastError = null;
}
public void markFailed(String error, Instant nextAttemptAt, int maxAttempts) {
attempts++;
lastError = error;
this.nextAttemptAt = nextAttemptAt;
status = attempts >= maxAttempts ? OutboxStatus.DEAD_LETTER : OutboxStatus.FAILED;
}
public void requeueAfterPublisherCrash() {
if (status == OutboxStatus.IN_FLIGHT) status = OutboxStatus.PENDING;
}
}
Wichtig ist die Unterscheidung zwischen FAILED und
DEAD_LETTER. FAILED bedeutet: automatisch
erneut versuchen. DEAD_LETTER bedeutet: automatische
Wiederholung wurde beendet, weil sonst ein Endlos-Fehler entsteht. Ein
gutes System macht Dead-Letter-Fälle sichtbar und replaybar, aber nicht
unsichtbar automatisch unendlich oft.
Der Publisher ist kein simpler Cron-Job, der
select * from outbox where sent=false liest. Ein
produktionsnaher Publisher muss Nachrichten kontrolliert beanspruchen,
in kleinen Batches senden, Fehler speichern und mit Backoff erneut
planen. Außerdem muss er nach einem Prozess-Crash mit halbfertigen
IN_FLIGHT-Nachrichten umgehen.
package com.example.outboxdeepdive.outbox;
import com.example.outboxdeepdive.broker.*;
import java.time.*;
// Pattern: Publisher - entkoppelt DB-Transaktion und Broker-Versand.
public final class OutboxPublisher {
private final OutboxRepository repository;
private final BrokerPort broker;
private final int batchSize;
private final int maxAttempts;
public OutboxPublisher(OutboxRepository repository, BrokerPort broker, int batchSize, int maxAttempts) {
this.repository = repository;
this.broker = broker;
this.batchSize = batchSize;
this.maxAttempts = maxAttempts;
}
public int publishDue(Instant now) {
int sent = 0;
for (OutboxMessage message : repository.findDue(now, batchSize)) {
message.claim();
try {
broker.publish(new EventEnvelope(message.id(), message.eventType(), message.eventVersion(), message.payload()));
message.markSent();
sent++;
}
catch (RuntimeException ex) {
message.markFailed(ex.getClass().getSimpleName() + ": " + ex.getMessage(), nextAttempt(now, message.attempts() + 1), maxAttempts);
}
}
return sent;
}
private Instant nextAttempt(Instant now, int attempt) {
long seconds = Math.min(300, (long)Math.pow(2, Math.max(1, attempt)));
return now.plusSeconds(seconds);
}
public void recoverInFlightAfterCrash() {
repository.all().forEach(OutboxMessage::requeueAfterPublisherCrash);
}
}
In einer echten Datenbank würde das Claiming meist mit kurzen
Transaktionen, FOR UPDATE SKIP LOCKED oder atomaren
Status-Updates umgesetzt. Dieser Kapitel erklärt das Prinzip ohne
DB-Abhängigkeit. Entscheidend ist: Mehrere Publisher-Instanzen dürfen
nicht dieselbe Nachricht gleichzeitig senden, und ein Crash darf eine
Nachricht nicht dauerhaft unsichtbar machen.
Wer Outbox wirklich verstehen will, muss die Crash-Matrix lesen können. Der Happy Path beweist wenig. Robustheit zeigt sich an den Stellen, an denen der Prozess stirbt oder eine abhängige Komponente nur teilweise geantwortet hat.
Die kritischste Zeile ist “nach Send vor SENT-Markierung”. Der Broker
kann das Event bereits an den Consumer geliefert haben, während die
Outbox-Zeile noch nicht als SENT markiert wurde. Beim
nächsten Publisher-Lauf kann dieselbe Nachricht erneut gesendet werden.
Das ist kein Bug im Outbox-Muster, sondern eine direkte Folge
mindestens-einmal-Zustellung. Deshalb braucht der Consumer
Idempotenz.
Merksatz: Outbox löst das Dual-Write-Problem zwischen Datenbank und Broker, aber sie macht keine exakt-einmal-Fachwirkung. Diese Verantwortung liegt beim Consumer.
Ein Consumer ist idempotent, wenn dieselbe Nachricht mehrfach
ankommen darf, ohne dass die fachliche Wirkung mehrfach ausgeführt wird.
Für Billing bedeutet das: dieselbe OrderAccepted-Nachricht
darf nicht zwei Rechnungen erzeugen. Für Inventory bedeutet es: dieselbe
Reservierung darf nicht zweimal Bestand reduzieren.
package com.example.outboxdeepdive.billing;
import com.example.outboxdeepdive.broker.EventEnvelope;
// Pattern: Idempotent Consumer - verarbeitet dasselbe Event mehrfach ohne doppelte Rechnung.
public final class BillingConsumer {
private final IdempotencyStore idempotencyStore;
private final InvoiceRepository invoices;
public BillingConsumer(IdempotencyStore idempotencyStore, InvoiceRepository invoices) {
this.idempotencyStore = idempotencyStore;
this.invoices = invoices;
}
public void onMessage(EventEnvelope envelope) {
if (idempotencyStore.alreadyProcessed(envelope.messageId())) return;
String orderId = extractOrderId(envelope.payload());
invoices.createInvoice(orderId);
idempotencyStore.markProcessed(envelope.messageId());
}
private String extractOrderId(String json) {
String marker = ""orderId":"";
int start = json.indexOf(marker);
if (start < 0) throw new IllegalArgumentException("orderId missing");
start += marker.length();
int end = json.indexOf('"', start);
return json.substring(start, end);
}
}
Der IdempotencyStore muss in echten Systemen dauerhaft
sein, nicht nur ein In-Memory-Set. Häufig nutzt man eine Tabelle mit
message_id als Unique Key. Wichtig ist die Reihenfolge:
fachliche Wirkung und Markierung als verarbeitet müssen ebenfalls
konsistent behandelt werden. Sonst entsteht ein neues Dual-Write-Problem
auf Consumer-Seite.
Retry ist ein Schutz gegen transiente Fehler. Aber Retry ohne Grenze ist eine Produktionsgefahr. Wenn ein Broker, Schema oder Consumer dauerhaft kaputt ist, erzeugt blindes Wiederholen nur Last, Logs und Folgefehler. Deshalb braucht der Publisher Versuchszähler, Backoff, Max-Attempts und klare Dead-Letter-Behandlung.
Im Lab sieht man das bewusst einfach: markFailed erhöht
die Versuchszahl und setzt nextAttemptAt. Nach dem
Maximalwert landet die Nachricht in DEAD_LETTER. In echten
Systemen kommt noch Operations-Logik dazu: Alerting, manuelle Freigabe,
Replay nach Fix, eventuell Quarantäne für fehlerhafte
Event-Versionen.
Ein Event ist ein Vertrag zwischen Teams und Systemen. Eine Java-Klasse kann schnell geändert werden, aber veröffentlichte Events leben oft länger als der Code, der sie ursprünglich erzeugt hat. Deshalb ist Event-Versionierung kein späteres Detail, sondern Teil des Designs.
Kompatible Änderungen sind Erweiterungen, die alte Consumer
ignorieren können. Gefährlich sind Bedeutungsänderungen: Wenn
amount früher Brutto und später Netto meint, ist die
Signatur vielleicht gleich, aber die Fachlichkeit kaputt. Ein
Deep-Dive-Buch muss solche Fehler sichtbar machen, weil sie in der
Praxis teurer sind als Syntaxfehler.
Die Tests in diesem Kapitel prüfen nicht nur, ob ein Event gesendet wird. Sie prüfen die entscheidenden Fehlerfälle: Order und Outbox werden gemeinsam geschrieben, Broker-Fehler lassen die Message retryfähig, doppelte Consumer-Zustellung erzeugt nur eine Rechnung, Max-Attempts führen zu Dead Letter.
package com.example.outboxdeepdive;
import com.example.outboxdeepdive.billing.*;
import com.example.outboxdeepdive.broker.*;
import com.example.outboxdeepdive.order.*;
import com.example.outboxdeepdive.outbox.*;
import com.example.outboxdeepdive.shared.*;
import java.time.Instant;
public final class Kapitel3Tests {
public static void main(String[] args) {
orderAndOutboxAreWrittenTogether();
brokerFailureKeepsMessageRetryable();
duplicateDeliveryCreatesOnlyOneInvoice();
maxAttemptsMovesToDeadLetter();
System.out.println("Kapitel3_TESTS_OK");
}
static void orderAndOutboxAreWrittenTogether() {
var orders = new InMemoryOrderRepository();
var outbox = new InMemoryOutboxRepository();
new PlaceOrderService(orders, outbox).placeOrder("O-1", Money.eur("10.00"), Instant.parse("2026-01-01T00:00:00Z"));
check(orders.size() == 1, "order saved");
check(outbox.all().size() == 1, "outbox saved");
check(outbox.all().getFirst().status() == OutboxStatus.PENDING, "pending");
}
static void brokerFailureKeepsMessageRetryable() {
var outbox = new InMemoryOutboxRepository();
var broker = new FakeBroker();
outbox.save(OutboxMessage.pending("Order", "O-2", "OrderAccepted", 1, "{"orderId":"O-2"}", Instant.parse("2026-01-01T00:00:00Z")));
broker.failNextPublishes(1);
var publisher = new OutboxPublisher(outbox, broker, 10, 3);
publisher.publishDue(Instant.parse("2026-01-01T00:00:00Z"));
check(outbox.all().getFirst().status() == OutboxStatus.FAILED, "failed after broker error");
publisher.publishDue(Instant.parse("2026-01-01T00:00:20Z"));
check(outbox.all().getFirst().status() == OutboxStatus.SENT, "sent after retry");
}
static void duplicateDeliveryCreatesOnlyOneInvoice() {
var idem = new InMemoryIdempotencyStore();
var invoices = new InvoiceRepository();
var consumer = new BillingConsumer(idem, invoices);
var event = new EventEnvelope(java.util.UUID.randomUUID(), "OrderAccepted", 1, "{"orderId":"O-3"}");
consumer.onMessage(event);
consumer.onMessage(event);
check(invoices.invoices().size() == 1, "only one invoice");
}
static void maxAttemptsMovesToDeadLetter() {
var outbox = new InMemoryOutboxRepository();
var broker = new FakeBroker();
broker.failNextPublishes(5);
outbox.save(OutboxMessage.pending("Order", "O-4", "OrderAccepted", 1, "{"orderId":"O-4"}", Instant.parse("2026-01-01T00:00:00Z")));
var publisher = new OutboxPublisher(outbox, broker, 10, 2);
publisher.publishDue(Instant.parse("2026-01-01T00:00:00Z"));
publisher.publishDue(Instant.parse("2026-01-01T00:01:00Z"));
check(outbox.all().getFirst().status() == OutboxStatus.DEAD_LETTER, "dead letter after max attempts");
}
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
Ein produktionsnahes Team würde diese Tests später mit echter Datenbank, echter Transaktionsgrenze und Testcontainers erweitern. Der Lernwert des JDK-only-Labs liegt darin, dass man die Mechanik ohne Framework-Ablenkung versteht.
Typische Fehler bei Outbox-Implementierungen sind selten Syntaxfehler. Häufig sind es Betriebs- und Architekturfehler:
(status, next_attempt_at, created_at).IN_FLIGHT-Nachrichten werden nach Crash nie wieder
freigegeben.Die wichtigste Produktionsregel lautet: Outbox ist ein technisches Muster mit fachlichen Konsequenzen. Wer Events verliert, erzeugt nicht nur ein technisches Ticket, sondern falsche Rechnungen, falsche Bestände, falsches Reporting oder fehlende Audit-Spuren.
Vor einer produktiven Outbox sollte man mindestens diese Fragen beantworten:
Wenn eine dieser Fragen nicht beantwortet ist, ist das System nicht fertig, sondern nur im Happy Path lauffähig.