Das nimmst du mit
- Atomare Zustandsänderungen erkennen
- Grenzen fachlich schneiden
- Rollback korrekt einordnen
- Nebenwirkungen nach Commit behandeln
Kapitelkompass
ACID ist kein Etikett für Methoden, sondern eine Aussage darüber, welche Zustände gemeinsam erfolgreich oder gar nicht sichtbar werden.
Order und Zahlung sollen konsistent bleiben, obwohl ein nachgelagerter Dienst zeitweise ausfällt.
Eine Transaktion sollte genau die Daten umfassen, deren Invarianten gemeinsam gelten.
Zu große Transaktionen erhöhen Kopplung, Sperrdauer und Ausfallradius.
Thema: Transaktionen wirklich verstehen
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.
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.
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:
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?
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
Fachliche Fehler:
CustomerBlocked
InventoryUnavailable
DuplicateCommand
PaymentDeclined
InvalidStateTransition
Technische Fehler:
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.
Transaktionsdesign ist ohne Tests schwer zu vertrauen. Besonders wichtig sind nicht nur happy-path-Tests, sondern Crash- und Fehler-Szenarien.
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:
Diese Tests sind bewusst ohne externe Infrastruktur gebaut. Spaeter kann man dieselben Szenarien mit echter Datenbank und Testcontainers wiederholen.
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 |
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.
Vor dem naechsten Kapitel sollte man diese Fragen beantworten koennen: