Transaktionen-und-ACID-Lab

32 Java-Dateien. 7 zentrale Dateien werden direkt mit echtem Quellcode und ihrem Zusammenspiel erklärt.

Zurück zu Code-Labs

Kapitel 02 · Transaktionen, Grenzen und ACID

Was dieses Lab zeigt

Vergleicht unsichere Dual Writes mit einer klaren Transaktionsgrenze. Das Lab zeigt atomare Änderungen, Rollback-Verhalten und die Kopplung zwischen Bestellung, Bestand und Outbox.

Lernziele

  • Transaktionsgrenzen erkennen
  • Dual-Write-Fehler nachvollziehen
  • Atomare Use Cases testen

Technik und Schwerpunkte

Java 2132 Java-Dateien1 Tests/RunnerACIDTransaction BoundaryOutbox
Echter Quellcode aus diesem Lab

Geführter Codepfad

Dieses Lab stellt den unsicheren Dual Write direkt der transaktional abgesicherten Variante gegenüber. Der Codepfad zeigt, wo der Fehler entsteht und wie Datenbank, Outbox und Publisher getrennte Verantwortungen erhalten.

Run4C1DemoBadDualWriteOrderServiceSafeOrderApplicationServiceTransactionManagerInMemoryDatabaseOutboxPublisherRun4C1TestRunner
Lesereihenfolge der zentralen Klassen. Die Pfeile zeigen den didaktischen Weg durch den realen Quellcode, nicht zwingend jeden Laufzeitaufruf.
1. Run4C1DemoFührt beide Varianten aus und macht den Unterschied zwischen fehleranfälligem Dual Write und sicherer Outbox-Lösung nachvollziehbar.
2. BadDualWriteOrderServicePersistiert Bestellung und Broker-Nachricht getrennt und demonstriert dadurch das konkrete Konsistenzfenster.
3. SafeOrderApplicationServiceSpeichert Bestellung und Outbox-Eintrag innerhalb derselben lokalen Transaktion.
4. TransactionManagerKoordiniert die simulierte Transaktion und entscheidet über Commit oder Rollback der beteiligten Datenstrukturen.
5. InMemoryDatabaseBildet den transaktionalen Datenbestand für Order und Outbox ab und macht Commit-/Rollback-Verhalten testbar.
6. OutboxPublisherÜbernimmt erst nach dem Commit die externe Veröffentlichung an den Broker.
7. Run4C1TestRunnerPrüft die Fehlerfälle und beweist, dass die sichere Variante keine halbfertigen Zustände zurücklässt.

1. Run4C1Demo

src/main/java/com/example/txdeepdive/application/Run4C1Demo.java
Java-Datei öffnen
Rolle im Ablauf

Führt beide Varianten aus und macht den Unterschied zwischen fehleranfälligem Dual Write und sicherer Outbox-Lösung nachvollziehbar.

Im Lesepfad folgt BadDualWriteOrderService: Persistiert Bestellung und Broker-Nachricht getrennt und demonstriert dadurch das konkrete Konsistenzfenster.

Typ
class Run4C1Demo
Verwendet
SafeOrderApplicationService, TransactionManager, InMemoryDatabase, OutboxPublisher
Verwendet von
Einstiege
main(String[] args)
package com.example.txdeepdive.application;

import com.example.txdeepdive.adapters.*;
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 Run4C1Demo {
    public static void main(String[] args) {
        TransactionManager tx = new TransactionManager();
        InMemoryDatabase db = new InMemoryDatabase();
        ProductId product = new ProductId("P-100");
        db.putStock(product, 10);
        OrderRepository orders = new InMemoryOrderRepository(db, tx);
        OutboxRepository outbox = new InMemoryOutboxRepository(db, tx);
        InventoryPort inventory = new TransactionalInventoryAdapter(db, tx);
        InMemoryEventBroker broker = new InMemoryEventBroker();
        SafeOrderApplicationService service = new SafeOrderApplicationService(tx, orders, inventory, outbox);
        PlaceOrderCommand command = new PlaceOrderCommand("idem-1", OrderId.of("O-100"), new CustomerId("C-100"), List.of(new OrderLine(product, 2, Money.eur("19.90"))));
        var result = service.placeOrder(command);
        new OutboxPublisher(outbox, broker).publishPending();
        System.out.println("RUN4C1_DEMO_OK result=" + result.isOk() + " orders=" + orders.count() + " outbox=" + outbox.count() + " broker=" + broker.publishedCount());
        System.out.println(String.join(" | ", tx.log()));
    }
}

2. BadDualWriteOrderService

src/main/java/com/example/txdeepdive/application/BadDualWriteOrderService.java
Java-Datei öffnen
Rolle im Ablauf

Persistiert Bestellung und Broker-Nachricht getrennt und demonstriert dadurch das konkrete Konsistenzfenster.

Im Lesepfad folgt SafeOrderApplicationService: Speichert Bestellung und Outbox-Eintrag innerhalb derselben lokalen Transaktion.

Typ
class BadDualWriteOrderService
Verwendet
TransactionManager
Verwendet von
Run4C1TestRunner
Einstiege
placeOrderAndCrashBeforeCommit(PlaceOrderCommand command)
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");
        });
    }
}

3. SafeOrderApplicationService

src/main/java/com/example/txdeepdive/application/SafeOrderApplicationService.java
Java-Datei öffnen
Rolle im Ablauf

Speichert Bestellung und Outbox-Eintrag innerhalb derselben lokalen Transaktion.

Im Lesepfad folgt TransactionManager: Koordiniert die simulierte Transaktion und entscheidet über Commit oder Rollback der beteiligten Datenstrukturen.

Typ
class SafeOrderApplicationService
Verwendet
TransactionManager
Verwendet von
Run4C1Demo, Run4C1TestRunner
Einstiege
placeOrder(PlaceOrderCommand command), placeOrderAndCrashBeforeCommit(PlaceOrderCommand command)
Application Service
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");
        });
    }
}

4. TransactionManager

src/main/java/com/example/txdeepdive/tx/TransactionManager.java
Java-Datei öffnen
Rolle im Ablauf

Koordiniert die simulierte Transaktion und entscheidet über Commit oder Rollback der beteiligten Datenstrukturen.

Im Lesepfad folgt InMemoryDatabase: Bildet den transaktionalen Datenbestand für Order und Outbox ab und macht Commit-/Rollback-Verhalten testbar.

Typ
class TransactionManager
Verwendet
Verwendet von
Run4C1Demo, BadDualWriteOrderService, SafeOrderApplicationService, Run4C1TestRunner
Einstiege
inTransaction(String name, Supplier<T> work), required(), active(), log()
Transaction Script Boundary
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); }
}

5. InMemoryDatabase

src/main/java/com/example/txdeepdive/adapters/InMemoryDatabase.java
Java-Datei öffnen
Rolle im Ablauf

Bildet den transaktionalen Datenbestand für Order und Outbox ab und macht Commit-/Rollback-Verhalten testbar.

Im Lesepfad folgt OutboxPublisher: Übernimmt erst nach dem Commit die externe Veröffentlichung an den Broker.

Typ
class InMemoryDatabase
Verwendet
Verwendet von
Run4C1Demo, Run4C1TestRunner
Einstiege
putStock(ProductId id, int quantity), orderCount(), outboxCount()
package com.example.txdeepdive.adapters;

import com.example.txdeepdive.domain.Order;
import com.example.txdeepdive.outbox.OutboxMessage;
import com.example.txdeepdive.shared.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public final class InMemoryDatabase {
    final Map<OrderId, Order> orders = new ConcurrentHashMap<>();
    final List<OutboxMessage> outbox = Collections.synchronizedList(new ArrayList<>());
    final Map<ProductId, Integer> stock = new ConcurrentHashMap<>();
    public void putStock(ProductId id, int quantity) { stock.put(id, quantity); }
    public int orderCount() { return orders.size(); }
    public int outboxCount() { return outbox.size(); }
}

6. OutboxPublisher

src/main/java/com/example/txdeepdive/application/OutboxPublisher.java
Java-Datei öffnen
Rolle im Ablauf

Übernimmt erst nach dem Commit die externe Veröffentlichung an den Broker.

Im Lesepfad folgt Run4C1TestRunner: Prüft die Fehlerfälle und beweist, dass die sichere Variante keine halbfertigen Zustände zurücklässt.

Typ
class OutboxPublisher
Verwendet
Verwendet von
Run4C1Demo, Run4C1TestRunner
Einstiege
publishPending()
Outbox Publisher
package com.example.txdeepdive.application;

import com.example.txdeepdive.outbox.OutboxMessage;
import com.example.txdeepdive.ports.*;

// Pattern: Outbox Publisher - Broker-Versand ist nachgelagert, wiederholbar und vom DB-Commit getrennt.
public final class OutboxPublisher {
    private final OutboxRepository outbox;
    private final EventBroker broker;
    public OutboxPublisher(OutboxRepository outbox, EventBroker broker) { this.outbox = outbox; this.broker = broker; }
    public int publishPending() {
        int sent = 0;
        for (OutboxMessage message : outbox.pending()) {
            broker.publish(message.eventType(), message.payload());
            message.markSent();
            sent++;
        }
        return sent;
    }
}

7. Run4C1TestRunner

src/test/java/com/example/txdeepdive/Run4C1TestRunner.java
Java-Datei öffnen
Rolle im Ablauf

Prüft die Fehlerfälle und beweist, dass die sichere Variante keine halbfertigen Zustände zurücklässt.

Damit ist der zentrale Pfad abgeschlossen; der Test-/Runner-Code und die vollständige Dateiliste darunter zeigen die übrigen Varianten.

Typ
class Run4C1TestRunner
Verwendet
BadDualWriteOrderService, SafeOrderApplicationService, TransactionManager, InMemoryDatabase, OutboxPublisher
Verwendet von
Einstiege
main(String[] args)
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 Run4C1TestRunner {
    public static void main(String[] args) {
        badDualWritePublishesEventAlthoughOrderRolledBack();
        safeOutboxRollsBackOrderAndOutboxTogether();
        successfulSafeUseCaseCommitsOrderInventoryAndOutbox();
        System.out.println("RUN4C1_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) { }
}
Alle Projektdateien öffnen (36 Einträge)
⌂ Cockpit