← Zurück

Kapitelkompass

Konsistenz braucht eine bewusste Grenze

ACID ist kein Etikett für Methoden, sondern eine Aussage darüber, welche Zustände gemeinsam erfolgreich oder gar nicht sichtbar werden.

InvariantAusgangspunktBeginEinordnenCommitAbsichernFolgewirkungErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Atomare Zustandsänderungen erkennen
  • Grenzen fachlich schneiden
  • Rollback korrekt einordnen
  • Nebenwirkungen nach Commit behandeln

Praxisfall

Order und Zahlung sollen konsistent bleiben, obwohl ein nachgelagerter Dienst zeitweise ausfällt.

Entscheidung

Eine Transaktion sollte genau die Daten umfassen, deren Invarianten gemeinsam gelten.

!

Typisches Risiko

Zu große Transaktionen erhöhen Kopplung, Sperrdauer und Ausfallradius.

02. Transaktionen, Grenzen und ACID

Thema: Transaktionen wirklich verstehen

1. Orientierung: Warum Kapitel existiert

Kapitel hat Transaktionen, Locking und Outbox bereits angerissen. Kapitel geht bewusst einen Schritt zurueck und macht die Transaktion selbst zum Lerngegenstand. Das Ziel ist nicht, eine Annotation wie @Transactional auswendig zu kennen, sondern zu verstehen, welche fachliche Wahrheit eine Transaktion schuetzt.

In vielen Enterprise-Projekten wird Transaktionalitaet zu frueh mit einem Framework gleichgesetzt. Dann entsteht Code, der scheinbar funktioniert, aber bei Timeouts, Broker-Ausfaellen, parallelen Requests oder Rollbacks falsche Nebenwirkungen erzeugt. Dieses Kapitel zeigt deshalb nicht nur die gute Loesung, sondern auch die schlechte Loesung.

Merksatz: Eine Transaktion ist kein Performance-Feature. Sie ist eine fachliche Konsistenzgrenze.

2. Transaktion als fachliche Konsistenzgrenze

Eine Transaktion beantwortet die Frage: Welche Aenderungen duerfen nur gemeinsam sichtbar werden? Bei einer Bestellung kann das bedeuten: Order anlegen, Lager reservieren, Audit-Eintrag schreiben und Outbox-Nachricht vorbereiten. Wenn eine dieser Aenderungen fehlschlaegt, darf der fachliche Zustand nicht halb sichtbar werden.

Transaction BoundaryDie Grenze entscheidet, was zusammen atomar erfolgreich oder fehlerhaft ist.Command kommt anHTTP, Batch, Message oder UI löst einen fachlichen Use Case aus.Application Service entscheidet GrenzeNicht Controller und nicht Repository definieren die fachliche Transaktion.Datenänderungen sammelnOrder, Inventory-Reservierung, Audit und Outbox werden in derselben Einheit geplant.CommitErst jetzt wird der neue Zustand dauerhaft sichtbar.Nach CommitPublisher, Benachrichtigung oder Reporting laufen entkoppelt und retrybar.

Ein haeufiger Fehler ist, Transaktionen entlang technischer Klassen zu schneiden: Repository-Methode A ist transaktional, Repository-Methode B ist transaktional, Service-Methode C ruft beide auf. Das kann zufaellig funktionieren, aber die fachliche Grenze liegt dann nicht sichtbar im Use Case. Besser ist: Der Application Service macht die Use-Case-Grenze deutlich.

ASCII-Mentalmodell:

TEXT
PlaceOrder Use Case
  ├─ Order validieren
  ├─ Inventory reservieren
  ├─ Order speichern
  ├─ Outbox schreiben
  └─ Commit oder Rollback als eine Einheit

Die Frage ist nicht: Welche Methode bekommt eine Annotation? Die Frage ist: Welche fachliche Aussage darf nach Commit wahr sein?

3. ACID nicht als Theorie, sondern als Projektrisiko

ACID wird oft als Definition gelernt und danach vergessen. In Enterprise-Systemen steckt hinter jedem Buchstaben ein konkretes Risiko.

ACID-Aspekt Bedeutung im Bestellprozess Typisches Risiko
Atomicity Order und Outbox werden gemeinsam committed Order existiert ohne Event oder Event ohne Order
Consistency Invarianten bleiben gueltig Order ist CONFIRMED, obwohl Inventory nicht reserviert wurde
Isolation parallele Transaktionen stoeren sich nicht unerwartet zwei Requests reservieren denselben Lagerbestand
Durability Commit bleibt dauerhaft Prozessneustart verliert bereits bestaetigte Order

Atomicity ist im Alltag meist das erste Problem: Ein Use Case hat mehrere Schreiboperationen. Ohne klare Grenze entstehen halbe Zustaende. Isolation wird im naechsten Vertiefungsschritt ausgebaut, weil dort Lost Update, Phantom Read und Deadlock-Szenarien mehr Raum brauchen.

Wichtig: ACID gilt fuer Ressourcen, die tatsaechlich an der Transaktion teilnehmen. Ein HTTP-Aufruf, eine E-Mail, ein Kafka-Send oder ein Payment-Provider ist normalerweise nicht automatisch Teil derselben Datenbanktransaktion.

4. Schlechte Loesung: direktes Dual Write

Die schlechte Loesung sieht auf den ersten Blick einfach aus: Order speichern und danach direkt ein Event senden. Genau so entstehen aber schwer auffindbare Produktionsfehler.

Schlechtes Dual-Write-SzenarioWarum direkte Broker-Kommunikation in einem Use Case gefährlich ist.Order speichern vorbereitenRepository arbeitet innerhalb einer noch nicht abgeschlossenen Transaktion.Event direkt publizierenBroker sieht OrderPlaced sofort, obwohl der DB-Commit noch nicht sicher ist.Fehler vor CommitException, Constraint-Fehler, Timeout oder Prozessabbruch.RollbackOrder ist nicht in der Datenbank, Event wurde aber bereits gesendet.FolgeschadenBilling, Reporting oder Notification reagieren auf eine Order, die nie existiert.
JAVA
package com.example.txdeepdive.application;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.tx.*;
// Anti-Pattern: Dual Write - Datenbank-Transaktion und Broker-Side-Effect werden unkoordiniert gemischt.
public final class BadDualWriteOrderService {
    private final TransactionManager txManager;
    private final OrderRepository orders;
    private final EventBroker broker;
    public BadDualWriteOrderService(TransactionManager txManager, OrderRepository orders, EventBroker broker) {
        this.txManager = txManager;
        this.orders = orders;
        this.broker = broker;
    }
public void placeOrderAndCrashBeforeCommit(PlaceOrderCommand command) {
    txManager.inTransaction("bad-place-order", () -> {
        Order order = new Order(command.orderId(), command.customerId(), command.lines());
        orders.save(order);
        broker.publish("orders", "OrderPlaced:" + order.id());
        // gefaehrlich: nicht rollbackfaehig
        throw new DualWriteFailure("crash after broker publish but before DB commit");
    }
);
}
}

Der Code sieht kurz aus, aber er hat eine gefaehrliche Eigenschaft: Der Broker-Versand ist eine nicht rollbackfaehige Nebenwirkung. Wenn nach dem publish() ein Fehler passiert, kann die Datenbanktransaktion zurueckrollen. Der Broker hat das Event aber bereits gesehen.

Produktionsfolge: Billing erzeugt vielleicht eine Rechnung, Reporting zaehlt Umsatz, Notification sendet eine Mail. Spaeter sucht jemand die Order in der Datenbank - sie existiert nicht. Genau solche Fehler sind teuer, weil sie nicht sofort im happy path auffallen.

5. Bessere Loesung: Order und Outbox gemeinsam committen

Die bessere Loesung besteht nicht darin, Kafka oder JMS irgendwie in die Datenbanktransaktion zu zwingen. Die bessere Loesung ist, die Event-Absicht als Datenbankzustand zu speichern. Diese Idee nennt man Transactional Outbox.

Bessere Transaktionsgrenze mit OutboxNicht der Broker ist Teil der DB-Transaktion, sondern eine Outbox-Zeile.Use Case startet TransaktionApplication Service kontrolliert fachliche Atomarität.Order speichernAggregate-Zustand und Versionsstand werden vorbereitet.Outbox schreibenEvent wird als Datenbankzeile in derselben Transaktion gespeichert.CommitOrder und Outbox werden gemeinsam sichtbar oder gemeinsam verworfen.Publisher nach CommitBroker-Versand ist retrybar und idempotent nachgelagert.
JAVA
package com.example.txdeepdive.application;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.outbox.OutboxMessage;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.shared.*;
import com.example.txdeepdive.tx.TransactionManager;
import java.time.Instant;
// Pattern: Application Service - orchestriert den Use Case und definiert die Transaction Boundary.
public final class SafeOrderApplicationService {
    private final TransactionManager txManager;
    private final OrderRepository orders;
    private final InventoryPort inventory;
    private final OutboxRepository outbox;
    public SafeOrderApplicationService(TransactionManager txManager, OrderRepository orders, InventoryPort inventory, OutboxRepository outbox) {
        this.txManager = txManager;
        this.orders = orders;
        this.inventory = inventory;
        this.outbox = outbox;
    }
public Result<PlaceOrderResult, DomainError> placeOrder(PlaceOrderCommand command) {
    return txManager.inTransaction("place-order", () -> {
        Order order = new Order(command.orderId(), command.customerId(), command.lines());
        for (OrderLine line : command.lines()) {
            Result<Void, DomainError> reserved = inventory.reserve(line.productId(), line.quantity());
            if (!reserved.isOk()) {
                txManager.required().setRollbackOnly();
                return Result.err(reserved.errorOrNull());
            }
    }
order.markReserved();
orders.save(order);
OrderPlacedEvent event = new OrderPlacedEvent(order.id(), order.customerId(), order.total(), Instant.now());
outbox.save(new OutboxMessage(order.id().value(), "OrderPlaced", event.toString()));
return Result.ok(new PlaceOrderResult(order.id(), "order reserved and outbox staged"));
}
);
}
public void placeOrderAndCrashBeforeCommit(PlaceOrderCommand command) {
    txManager.inTransaction("safe-place-order-crash", () -> {
        Order order = new Order(command.orderId(), command.customerId(), command.lines());
        orders.save(order);
        outbox.save(new OutboxMessage(order.id().value(), "OrderPlaced", "payload"));
        throw new RuntimeException("crash before commit");
    }
);
}
}

Diese Loesung verschiebt das Problem bewusst: Der Use Case sendet nicht direkt an den Broker. Er schreibt eine Outbox-Nachricht. Wenn der Commit gelingt, gibt es Order und Outbox. Wenn der Commit fehlschlaegt, gibt es beides nicht. Der Publisher arbeitet danach asynchron und kann wiederholen.

Trade-off: Outbox macht das System nicht magisch exakt-einmal. Sie macht die Zustandsaussage sauberer: Der fachliche Zustand und die Absicht, ein Event zu senden, sind atomar verbunden.

6. Commit und Rollback wirklich verstehen

Rollback ist kein universeller Zeitreise-Knopf. Rollback verwirft nur Aenderungen, die unter Kontrolle der Transaktion stehen. Deshalb ist es so wichtig, externe Nebenwirkungen aus der Transaktion herauszuhalten oder ueber Outbox/Inbox/idempotente Verarbeitung abzusichern.

Commit und Rollback mental modellierenRollback macht nur das rückgängig, was unter Kontrolle der Transaktion steht.Vor TransaktionKein exklusiver Anspruch auf fachliche Wahrheit.Während TransaktionÄnderungen sind geplant, validiert und möglicherweise gelockt.Commit-PfadAlle geplanten Änderungen werden dauerhaft.Rollback-PfadGeplante DB-Änderungen werden verworfen.Nicht rollbackfähigExterne E-Mail, Broker-Send, Payment-API oder Dateischreiben bleiben Nebenwirkung.

Ein gutes mentales Modell ist: Waehrend der Transaktion werden Aenderungen vorbereitet. Beim Commit werden sie dauerhaft sichtbar. Beim Rollback werden sie verworfen. In echten Datenbanken kommen Locks, Isolation und Write-Ahead-Logs dazu. Das Grundprinzip bleibt aber: Nur transaktionale Ressourcen koennen gemeinsam committed oder zurueckgerollt werden.

Typische nicht rollbackfaehige Nebenwirkungen:

Diese Nebenwirkungen sind nicht verboten. Sie brauchen nur eine andere Architektur: Outbox, Saga, Kompensation, idempotente Consumer oder explizite Statusmodelle.

7. Unit of Work im Code-Lab

Das Code-Lab benutzt eine kleine TransactionManager-/Transaction-Simulation, damit Commit und Rollback sichtbar werden. Das ersetzt keine echte Datenbank, macht aber das Prinzip sehr klar: Repositories wenden Aenderungen nicht sofort an, sondern registrieren sie als staged changes.

JAVA
package com.example.txdeepdive.tx;
import java.util.*;
import java.util.function.Supplier;
// Pattern: Transaction Script Boundary - der Application Service fuehrt den Use Case in einer klaren Grenze aus.
public final class TransactionManager {
    private final ThreadLocal<Transaction> current = new ThreadLocal<>();
    private final List<String> log = new ArrayList<>();
    public <T> T inTransaction(String name, Supplier<T> work) {
        if (current.get() != null) throw new IllegalStateException("nested transactions are not supported in this lab");
        Transaction tx = new Transaction(name);
        current.set(tx);
        log.add("BEGIN " + name);
        try {
            T result = work.get();
            if (tx.rollbackOnly()) {
                log.add("ROLLBACK_ONLY " + name + " staged=" + tx.size());
                return result;
            }
        tx.commit();
        log.add("COMMIT " + name + " staged=" + tx.size());
        return result;
    }
catch (RuntimeException ex) {
    log.add("ROLLBACK " + name + " reason=" + ex.getClass().getSimpleName() + " staged=" + tx.size());
    throw ex;
}
finally {
    current.remove();
}
}
public Transaction required() {
    Transaction tx = current.get();
    if (tx == null) throw new IllegalStateException("no active transaction");
    return tx;
}
public boolean active() {
    return current.get() != null;
}
public List<String> log() {
    return List.copyOf(log);
}
}

In einem echten Framework uebernimmt die Infrastruktur viel davon: EntityManager, Connection, TransactionManager oder Container. Trotzdem sollte man das Verhalten verstehen. Sonst verwechselt man Annotationen mit Architektur.

Warum dieses Lab sinnvoll ist:

8. Fachliche Fehler vs technische Fehler

Nicht jeder Fehler ist ein Grund, eine Exception als Steuerfluss zu missbrauchen. InventoryUnavailable ist ein fachlicher Fehler. Der Kunde hat mehr bestellt als verfuegbar ist. Das System ist nicht kaputt. Ein Datenbank-Timeout ist dagegen technisch und muss transaktional sauber abgebrochen werden.

Exception- und Rollback-EntscheidungNicht jede Exception ist gleich; fachliche und technische Fehler brauchen unterschiedliche Behandlung.Fachlicher FehlerInventoryUnavailable, CustomerBlocked, DuplicateCommand: sichtbar zurückgeben.Technischer FehlerDB-Timeout, Constraint-Problem, Connection-Abbruch: Rollback und Observability.Unchecked ExceptionFrameworks rollen oft automatisch zurück, aber Design darf nicht blind davon abhängen.Checked ExceptionRollback-Regeln können frameworkabhängig sein; explizit dokumentieren.Result-ModellUse Case kann fachliche Fehler testen, ohne technische Exceptions zu missbrauchen.

Fachliche Fehler:

TEXT
CustomerBlocked
InventoryUnavailable
DuplicateCommand
PaymentDeclined
InvalidStateTransition

Technische Fehler:

TEXT
ConnectionTimeout
DeadlockVictim
ConstraintViolation durch Race Condition
SerializationFailure
BrokerUnavailable beim Publisher

Im Code-Lab werden fachliche Fehler ueber Result<T, DomainError> ausgedrueckt. Dadurch sind Tests klarer. Rollback bei technischen Fehlern bleibt trotzdem wichtig.

9. Tests als Sicherheitsnetz fuer Transaktionen

Transaktionsdesign ist ohne Tests schwer zu vertrauen. Besonders wichtig sind nicht nur happy-path-Tests, sondern Crash- und Fehler-Szenarien.

JAVA
package com.example.txdeepdive;
import com.example.txdeepdive.adapters.*;
import com.example.txdeepdive.application.*;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.shared.*;
import com.example.txdeepdive.tx.*;
import java.util.List;
public final class Kapitel1TestRunner {
    public static void main(String[] args) {
        badDualWritePublishesEventAlthoughOrderRolledBack();
        safeOutboxRollsBackOrderAndOutboxTogether();
        successfulSafeUseCaseCommitsOrderInventoryAndOutbox();
        System.out.println("Kapitel1_TESTS_OK");
    }
static Fixtures fixtures() {
    TransactionManager tx = new TransactionManager();
    InMemoryDatabase db = new InMemoryDatabase();
    ProductId product = new ProductId("P-42");
    db.putStock(product, 5);
    OrderRepository orders = new InMemoryOrderRepository(db, tx);
    OutboxRepository outbox = new InMemoryOutboxRepository(db, tx);
    InventoryPort inventory = new TransactionalInventoryAdapter(db, tx);
    InMemoryEventBroker broker = new InMemoryEventBroker();
    return new Fixtures(tx, db, product, orders, outbox, inventory, broker);
}
static PlaceOrderCommand command(ProductId product, String orderId) {
    return new PlaceOrderCommand("idem-" + orderId, OrderId.of(orderId), new CustomerId("C-42"), List.of(new OrderLine(product, 2, Money.eur("12.50"))));
}
static void badDualWritePublishesEventAlthoughOrderRolledBack() {
    Fixtures f = fixtures();
    BadDualWriteOrderService bad = new BadDualWriteOrderService(f.tx, f.orders, f.broker);
    try {
        bad.placeOrderAndCrashBeforeCommit(command(f.product, "O-BAD"));
        throw new AssertionError("expected failure");
    }
catch (DualWriteFailure expected) {
}
assertEquals(0, f.orders.count(), "order must be rolled back");
assertEquals(1, f.broker.publishedCount(), "event was already published and cannot be rolled back");
}
static void safeOutboxRollsBackOrderAndOutboxTogether() {
    Fixtures f = fixtures();
    SafeOrderApplicationService safe = new SafeOrderApplicationService(f.tx, f.orders, f.inventory, f.outbox);
    try {
        safe.placeOrderAndCrashBeforeCommit(command(f.product, "O-SAFE-FAIL"));
        throw new AssertionError("expected failure");
    }
catch (RuntimeException expected) {
}
assertEquals(0, f.orders.count(), "order rolled back");
assertEquals(0, f.outbox.count(), "outbox rolled back with order");
assertEquals(0, f.broker.publishedCount(), "broker did not see uncommitted data");
}
static void successfulSafeUseCaseCommitsOrderInventoryAndOutbox() {
    Fixtures f = fixtures();
    SafeOrderApplicationService safe = new SafeOrderApplicationService(f.tx, f.orders, f.inventory, f.outbox);
    Result<PlaceOrderResult, DomainError> result = safe.placeOrder(command(f.product, "O-OK"));
    assertTrue(result.isOk(), "result should be ok");
    assertEquals(1, f.orders.count(), "order committed");
    assertEquals(1, f.outbox.count(), "outbox committed");
    assertEquals(3, f.inventory.available(f.product), "inventory decremented after commit");
    new OutboxPublisher(f.outbox, f.broker).publishPending();
    assertEquals(1, f.broker.publishedCount(), "publisher sends after commit");
}
static void assertTrue(boolean value, String msg) {
    if (!value) throw new AssertionError(msg);
}
static void assertEquals(int expected, int actual, String msg) {
    if (expected != actual) throw new AssertionError(msg + " expected=" + expected + " actual=" + actual);
}
record Fixtures(TransactionManager tx, InMemoryDatabase db, ProductId product, OrderRepository orders, OutboxRepository outbox, InventoryPort inventory, InMemoryEventBroker broker) {
}
}

Die drei wichtigsten Tests in Kapitel sind:

  1. Die schlechte Dual-Write-Loesung publiziert ein Event, obwohl die Order zurueckgerollt wurde.
  2. Die sichere Outbox-Loesung rollt Order und Outbox gemeinsam zurueck.
  3. Der erfolgreiche Use Case committed Order, Inventory-Reservierung und Outbox. Danach sendet der Publisher.

Diese Tests sind bewusst ohne externe Infrastruktur gebaut. Spaeter kann man dieselben Szenarien mit echter Datenbank und Testcontainers wiederholen.

10. Transaction Boundary Patterns und Anti-Patterns

Gute Muster:

Muster Zweck
Application Service Boundary Use Case definiert die Transaktion
Unit of Work Aenderungen sammeln und atomar committen
Repository Speicherzugriff kapseln
Outbox Event-Absicht mit Fachzustand committen
Idempotency Key doppelte Commands kontrollieren
Result Type fachliche Fehler sichtbar modellieren

Anti-Patterns:

Anti-Pattern Problem
Broker Publish vor Commit Event kann Zustand referenzieren, der nie committed wurde
Long Transaction mit Remote Calls Locks bleiben zu lange, Timeout-Risiko steigt
Transaktion im Controller fachliche Grenze ist ueber Adapter verstreut
Repository startet heimlich eigene Transaktion Use Case verliert Kontrolle ueber Atomaritaet
Exceptions fuer fachliche Normalfaelle Tests und API-Fehler werden unklar
Catch-and-ignore Rollback und Fehlerbeobachtung werden verschluckt

11. Produktionshinweise: Was im echten Betrieb wichtig wird

In Produktion sind Transaktionen nicht nur Code. Sie haben Auswirkungen auf Betrieb, Datenbank, Monitoring und Fehleranalyse.

Worauf man achten sollte:

Ein wichtiges Betriebszeichen: Wenn die Outbox immer weiter waechst, ist nicht die Transaktion kaputt. Dann ist meist Publisher, Broker, Consumer oder Backpressure das Problem.

12. Checkliste Kapitel

Vor dem naechsten Kapitel sollte man diese Fragen beantworten koennen:

⌂ Cockpit