Lab 06

Event-driven Outbox

Verhindere verlorene Events durch Outbox, Idempotenz und Publisher-Status.

OutboxKafka/JMSIdempotenzTransaktion
SchwierigkeitFortgeschritten
Dauer90–120 Min
LernstufeStufe 6 – Praxislabor und Capstone
Arbeitsweise: Erst den Vorher-Code öffnen, dann Analyse lesen, anschließend die Nachher-Lösung und den Test vergleichen. Alle Abschnitte sind standardmäßig geschlossen.

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.

OrderConfirmationService.javaJAVA
@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.

outbox-table.sqlSQL
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;
ConfirmOrderUseCase.javaJAVA
@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()))
    ));
}
OutboxPublisher.javaJAVA
@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.

OutboxUseCaseTest.javaJAVA
@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.
⌂ Cockpit