Event-driven Outbox
Verhindere verlorene Events durch Outbox, Idempotenz und Publisher-Status.
Workshop-Slice
Dieses Lab zeigt einen konkreten Enterprise-Fehler mit Vorher/Nachher-Code. Die Beispiele sind bewusst fachlich benannt, damit du Architekturentscheidungen und nicht nur Syntax übst.
Lernziel
Du veröffentlichst Domain Events so, dass Datenbankänderung und Event-Erzeugung atomar nachvollziehbar sind.
Ausgangslage
Ein Service speichert die Bestellung und sendet danach direkt ein Kafka-Event. Wenn der Prozess zwischen DB-Commit und Publish abstürzt, ist der Zustand inkonsistent.
Vorher: problematischer Code
Datenänderung und Event-Publish liegen in zwei technischen Welten ohne gemeinsame Wiederanlaufstrategie.
@Transactional
public void confirm(OrderId id) {
Order order = repository.findById(id).orElseThrow();
order.confirm();
repository.save(order);
// Problem: Kafka ist nicht Teil der DB-Transaktion.
kafkaTemplate.send("order-events", id.value(), new OrderConfirmedEvent(id.value()));
}
Analyse: Was ist daran schlecht?
- Nach DB-Commit kann Publish fehlschlagen.
- Nach Publish kann die Transaktion zurückrollen.
- Keine Wiederaufnahme offener Events.
- Consumer können Duplikate nicht sicher erkennen.
Nachher: bessere Lösung
Die Zielversion speichert fachliche Änderung und Outbox-Eintrag in derselben DB-Transaktion. Ein Publisher versendet später idempotent.
create table outbox_event (
event_id uuid primary key,
aggregate_type varchar(80) not null,
aggregate_id varchar(80) not null,
event_type varchar(120) not null,
payload jsonb not null,
created_at timestamp not null,
published_at timestamp null,
publish_attempts integer not null default 0
);
create index ix_outbox_unpublished on outbox_event(created_at) where published_at is null;
@Transactional
public void confirm(OrderId id) {
Order order = repository.findById(id).orElseThrow();
order.confirm();
repository.save(order);
// Pattern: Outbox
// Zweck: Domain-Änderung und Event-Auftrag werden atomar gespeichert.
outbox.store(OutboxEvent.from(
UUID.randomUUID(),
"Order",
id.value(),
"OrderConfirmed",
json.write(new OrderConfirmedPayload(id.value(), Instant.now()))
));
}
@Scheduled(fixedDelayString = "${outbox.publisher.delay:PT5S}")
void publishOpenEvents() {
for (OutboxEvent event : outboxRepository.lockNextBatch(100)) {
kafka.send("order-events", event.aggregateId(), event.payload());
outboxRepository.markPublished(event.eventId(), Instant.now());
}
}
Test / Prüfnachweis
Dieser Abschnitt zeigt, wie du die Verbesserung nachweist. Es ist bewusst kein reiner Happy-Path-Test, sondern prüft ein Risiko aus dem Vorher-Teil.
@Test
void confirmationCreatesOrderAndOutboxEventInSameTransaction() {
useCase.confirm(new OrderId("O-2024"));
assertThat(orderRepository.findByBusinessId("O-2024").status()).isEqualTo(CONFIRMED);
assertThat(outboxRepository.findUnpublished())
.extracting(OutboxEvent::eventType)
.containsExactly("OrderConfirmed");
}
Typische Fehler
- Kafka direkt im Domain-Objekt aufrufen.
- Outbox ohne eindeutige eventId speichern.
- Publisher löscht Events sofort statt publish-Status zu setzen.
- Consumer ohne Idempotenz-Key bauen.
Deep-Learning-Bezug
Die Links führen zum ausführlichen Inhalt; die Deep-Learning-Seite bleibt nur die Lernlandkarte und kopiert den Inhalt nicht doppelt.
Prüfcheckliste
- Outbox-Eintrag entsteht in derselben Transaktion wie Domain-Änderung.
- Event hat stabile eventId.
- Publisher kann Wiederanlauf nach Absturz.
- Consumer-Idempotenz ist eingeplant.