← Zurück

Kapitelkompass

Vom Java-Modell zum zuverlässigen Ereignis

Die Lernroute verbindet Sprache, Domänenmodell, Ports, Transaktion und Outbox zu einem durchgängigen Bestellablauf.

ModellAusgangspunktUse CaseEinordnenTransaktionAbsichernOutboxErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Wertobjekte und Aggregate sauber modellieren
  • Use Case und Infrastruktur trennen
  • Transaktionsgrenzen sichtbar machen
  • Events zuverlässig weitergeben

Praxisfall

Eine Bestellung wird validiert, gespeichert und gemeinsam mit einer Outbox-Nachricht festgeschrieben.

Entscheidung

Beginne beim fachlichen Invariant. Framework und Persistenz folgen erst, wenn die Grenze klar ist.

!

Typisches Risiko

Wenn Modell, Datenbank und Messaging gleichzeitig entworfen werden, verschwimmen Verantwortungen.

01. Grundlagen bis Outbox - integrierter Deep Dive

Ziel: Grundlagen und Outbox wurden nicht nur gesammelt, sondern inhaltlich neu verdichtet: Java-Grundlagen, Objektmodell, Collections, Fehlerbehandlung, JVM, Maven/Module, Clean Code, Testing, Enterprise-Domäne, DDD, Ports/Adapter, JDBC/JPA-Mentalmodell, Transaktionen, Locking und Outbox.

Diese Fallstudie ist bewusst langsamer und tiefer. Er behandelt weniger neue Themen, erklärt dafür die vorhandenen Themen so, dass daraus ein echtes Lernbuch und kein lose gepacktes ZIP entsteht.

So liest du dieses Buch

  1. Erst die Grundlagenkapitel lesen, auch wenn du Java schon kennst.
  2. Danach die Fallstudie Order-to-Invoice im Code öffnen.
  3. Zu jedem Kapitel den passenden Test oder die passende Klasse im Code suchen.
  4. Bei Transaktionen/Outbox langsam lesen: Hier entstehen die meisten Enterprise-Produktionsfehler.

1. Java-Grundlagen wirklich verstehen

Java-Grundlagen wirklich verstehen

Warum Grundlagen in Enterprise-Projekten nicht optional sind

In Enterprise-Systemen entstehen die teuersten Fehler selten durch fehlende Syntaxkenntnis. Sie entstehen, weil Objektidentität, Mutabilität, Referenzen, Gleichheit, Exception-Flüsse oder Nebenläufigkeit falsch verstanden werden. Ein OrderService, der fachlich korrekt aussieht, kann trotzdem Daten verlieren, wenn ein mutable Objekt als HashMap-Key dient, wenn ein Entity-Zustand außerhalb der Transaktion verändert wird oder wenn ein Thread einen veralteten Wert sieht.

Die Fallstudie beginnt deshalb wieder bei Java selbst. Java-Grundlagen sind nicht Vorwissen, das man schnell überspringt, sondern die mechanische Basis aller späteren Architekturentscheidungen.

Primitive, Objekte und Referenzen

Ein primitiver Wert wie int liegt direkt im Stack-Frame oder in einem Objektfeld. Eine Variable vom Typ Order enthält dagegen eine Referenz. Zwei Variablen können auf dasselbe Objekt zeigen. Genau daraus entstehen viele Enterprise-Probleme: Ein Cache gibt ein Objekt zurück, ein Service verändert es, ein anderer Service hält dieselbe Referenz und sieht plötzlich einen neuen Zustand. Das ist kein Framework-Problem, sondern Java-Grundlage.

Ein sauberer Domain-Kern reduziert solche Überraschungen. Value Objects bleiben immutable. Aggregates ändern Zustand kontrolliert über Methoden. Ports geben nicht einfach interne Collections zurück. Repositories geben Aggregate zurück, deren Lebensdauer und Transaktionskontext man bewusst betrachtet.

Schlechte Lösung: fachliche Werte als rohe Strings

Viele Legacy-Systeme modellieren Geld, IDs und Status als String, BigDecimal und int. Das wirkt flexibel, zerstört aber Fachlichkeit: Eine Kundennummer kann versehentlich als Produktnummer verwendet werden, Geld kann ohne Währung addiert werden, Statuswerte werden als magische Zahlen verglichen. Der Compiler kann dann keine fachlichen Fehler verhindern.

Bessere Lösung: Value Objects

Value Objects geben fachlichen Werten eine Form. Money, CustomerId, OrderId und ProductId sind klein, immutable und validiert. Dadurch wird der Code länger an der Oberfläche, aber viel robuster in der Tiefe. Ein Money kann nur mit gleicher Währung addiert werden. Eine CustomerId ist keine ProductId. Tests werden klarer, Maps stabiler, Logs aussagekräftiger.

JAVA
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
public Money add(Money other) {
    if (!currency.equals(other.currency())) {
        throw new IllegalArgumentException("currency mismatch");
    }
return new Money(amount.add(other.amount()), currency);
}
}

Deep-Dive-Lesebrille

Kapitel-Checkliste

2. Objektmodell, Referenzen, Stack und Heap

Objektmodell, Referenzen, Stack und Heap

Stack und Heap fachlich lesen

Bei jedem Methodenaufruf entsteht ein Stack Frame mit lokalen Variablen. Objekte leben auf dem Heap. Die Variable order ist nicht die Bestellung selbst, sondern eine Referenz auf ein Objekt. Wenn ein Application Service dieselbe Referenz an mehrere Helfer weitergibt, arbeiten alle an demselben Zustand. Das ist gut, wenn ein Aggregate kontrolliert verändert wird. Es ist gefährlich, wenn fremde Services beliebig Listen und Maps mutieren können.

Mutable State als Kostenstelle

Mutable State ist nicht grundsätzlich schlecht. Eine Bestellung ändert ihren Status; ein Persistence Context tracked Änderungen; ein Lock Manager hält interne Zustände. Kritisch wird Mutabilität, wenn sie unkontrolliert, geteilt oder unsichtbar ist. Deshalb gibt das Order-Aggregate seine Lines nur als Kopie zurück. Der Aufrufer kann die interne Liste nicht heimlich verändern.

Warum defensive Kopien wichtig sind

In Enterprise-Code sieht man oft Methoden wie List<OrderLine> getLines() { return lines; }. Damit wird die Invariante des Aggregates nach außen verschenkt. Jeder Aufrufer kann Positionen hinzufügen, ohne Validierung, ohne Event und ohne Repricing. Eine defensive Kopie ist nicht Luxus, sondern Schutz der Fachlichkeit.

Gleichheit, Identität und Lebensdauer

Entities haben Identität über Zeit. Value Objects haben Gleichheit über Inhalt. Das klingt theoretisch, entscheidet aber direkt über HashMaps, Caches, Sets und JPA-Verhalten. Eine Entity mit Datenbank-ID, die erst nach dem Persistieren entsteht, ist ein gefährlicher Key in einem HashSet. Ein Value Object wie ProductId ist dagegen stabil.

Deep-Dive-Lesebrille

Kapitel-Checkliste

3. Value Objects, Records und Immutability

Value Objects, Records und Immutability

Records sind nicht nur kürzere Klassen

Records sind in Java 17/21 besonders wertvoll für DTOs, Commands, IDs und Value Objects. Sie machen die Absicht sichtbar: Daten werden getragen, aber nicht beliebig verändert. Trotzdem sind Records nicht automatisch fachlich korrekt. Ein Record mit einer mutablen Liste als Komponente bleibt indirekt veränderbar. Deshalb muss man auch bei Records validieren und kopieren.

Compact Constructor als Invariantenschutz

Der compact constructor eines Records ist eine gute Stelle für Null-Prüfung, Normalisierung und fachliche Mindestregeln. Money normalisiert die Skala. IDs prüfen leere Werte. Commands können eine defensive Kopie ihrer Lines erzeugen. Der Vorteil: Ungültige Objekte entstehen gar nicht erst.

JAVA
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
public Money add(Money other) {
    if (!currency.equals(other.currency())) {
        throw new IllegalArgumentException("currency mismatch");
    }
return new Money(amount.add(other.amount()), currency);
}
}

Records und JPA Entities nicht verwechseln

JPA-Entities brauchen üblicherweise einen Lebenszyklus, Lazy Loading, Identität und oft einen no-args Constructor. Records passen gut an Rändern: API-DTOs, Commands, Query-Results und Value Objects. Eine Entity als Record ist nur in sehr speziellen Fällen sinnvoll. Das Buch trennt deshalb Domain-Aggregate und Value Objects bewusst.

Immutability reduziert Testaufwand

Unveränderliche Werte sind einfacher zu testen. Wenn Money.add() ein neues Objekt zurückgibt, muss kein Test prüfen, ob ein versteckter Zustand geändert wurde. Wenn OrderLine immutable ist, kann ein Test sicher sein, dass Preis und Menge stabil bleiben. Immutability ist deshalb nicht nur Stil, sondern ein Produktivitätsfaktor.

Deep-Dive-Lesebrille

Kapitel-Checkliste

4. Collections, Generics, Streams und Optional tief verstehen

Collections, Generics, Streams und Optional tief verstehen

Collections sind Architektur, nicht nur Container

Eine List bedeutet Reihenfolge und Duplikate. Ein Set bedeutet fachliche Eindeutigkeit. Eine Map bedeutet Zugriff über einen Key. Diese Wahl ist nicht neutral. Wenn OrderLine in einem Set liegt, entscheidet equals/hashCode, ob zwei Positionen fachlich gleich sind. Wenn eine Liste verwendet wird, muss der Code Duplikate bewusst behandeln. Enterprise-Bugs entstehen oft, weil Collection-Typen technisch statt fachlich gewählt wurden.

HashMap und mutable Keys

Eine HashMap berechnet den Bucket aus hashCode. Ändert sich der HashCode eines Keys nach dem Einfügen, liegt der Eintrag im falschen Bucket. Das passiert bei mutable Keys, aber auch bei Entities, deren ID nachträglich gesetzt wird. Deshalb sind immutable Value-Object-IDs robuste Keys.

JAVA
Map<CustomerKey, String> cache = new HashMap<>();
CustomerKey key = new CustomerKey("C-100", "AT");
cache.put(key, "premium");
key.setCountry("DE");
// hashCode ändert sich nach dem Einfügen
System.out.println(cache.get(key));
// meistens null - der Eintrag liegt im falschen Bucket

Generics: Typsicherheit als Architekturhilfe

Generics verhindern, dass ein Repository versehentlich falsche Aggregate zurückgibt. Repository<Order, OrderId> kann nicht plötzlich eine Invoice liefern. Wildcards helfen bei APIs, die nur lesen oder nur schreiben. Aber zu komplizierte Generics können die Lesbarkeit zerstören. Im Enterprise-Code ist die Faustregel: Typen sollen Fachlichkeit ausdrücken, nicht mathematische Kunststücke.

Streams und Lesbarkeit

Streams sind gut für Transformationen: filtern, gruppieren, mappen, aggregieren. Sie sind schlechter, wenn viele Nebenwirkungen, komplexe Fehlerbehandlung oder mehrere externe Ports beteiligt sind. Ein Stream, der Payment aufruft, Inventory reserviert und Events schreibt, ist kein eleganter Code, sondern versteckte Orchestrierung. Use Cases sollten lesbar bleiben.

Optional richtig einsetzen

Optional ist ein Rückgabetyp für möglicherweise fehlende Werte. Es ist kein Feldtyp für JPA-Entities und keine Ausrede, Fehlerdetails zu verlieren. Ein Repository kann Optional<Order> zurückgeben. Ein Use Case sollte bei fachlichen Fehlern oft besser Result<T, DomainError> verwenden, weil Optional.empty() nicht erklärt, ob etwas fehlt, gesperrt, abgelehnt oder doppelt ist.

Deep-Dive-Lesebrille

Kapitel-Checkliste

5. Fehlerbehandlung und fachliche Result-Typen

Exceptions sind nicht alle gleich

Technische Fehler und fachliche Ablehnungen brauchen unterschiedliche Modelle. Eine nicht erreichbare Datenbank ist technisch. Ein gesperrter Kunde ist fachlich. Eine abgelehnte Zahlung ist fachlich, aber oft von einem externen System gemeldet. Wenn alles als RuntimeException fliegt, verliert die API-Schicht die Möglichkeit, saubere Problem-Details, Statuscodes und Retry-Entscheidungen zu treffen.

Sealed Domain Errors

Mit sealed interfaces kann Java ausdrücken, welche Fehlerfamilie vollständig ist. Dadurch zwingt ein switch-Ausdruck später zur Vollständigkeit. Das ist besonders wertvoll für REST-Fehler, UI-Meldungen und Tests. Neue Fehlerarten werden sichtbar, statt still im Log zu verschwinden.

JAVA
String apiMessage(DomainError error) {
    return switch (error) {
        case DomainError.CustomerBlocked e -> "Kunde ist gesperrt: " + e.customerId();
        case DomainError.InventoryUnavailable e -> "Lagerbestand fehlt für " + e.productId();
        case DomainError.PaymentDeclined e -> "Zahlung abgelehnt: " + e.reason();
        case DomainError.InvalidState e -> "Ungültiger Statusübergang: " + e.action();
        case DomainError.ConcurrentModification e -> "Bitte neu laden: " + e.aggregateId();
        case DomainError.DuplicateCommand e -> "Auftrag wurde bereits verarbeitet: " + e.idempotencyKey();
    };
}

Result statt Exception für erwartbare Fachfehler

Ein Result<PlaceOrderResult, DomainError> sagt: Der Use Case kann fachlich scheitern, und dieser Fehler ist Teil der Schnittstelle. Das ist kein Ersatz für alle Exceptions. Programmierfehler, Infrastrukturfehler und kaputte Invarianten dürfen weiterhin Exceptions sein. Aber erwartbare Business-Ablehnungen sollten nicht wie technische Abstürze aussehen.

Fehler in Transaktionen

Bei fachlichen Fehlern sollte der Use Case kontrolliert abbrechen, ohne halb gespeicherte Zustände zu hinterlassen. Bei technischen Fehlern muss die Transaktion rollbacken. Bei Outbox-Publisher-Fehlern darf die Business-Transaktion bereits committed sein; der Publisher arbeitet später nach. Diese Unterscheidung ist zentral für robuste Enterprise-Systeme.

Deep-Dive-Lesebrille

Kapitel-Checkliste

6. JVM-Grundlagen, Classloading, GC und Concurrency-Basis

JVM-Grundlagen, Classloading, GC und Concurrency-Basis

Von Java-Code zu laufendem Prozess

javac erzeugt Bytecode. Die JVM lädt Klassen, prüft Bytecode, interpretiert zunächst und optimiert heißen Code später per JIT. Enterprise-Performance hängt deshalb nicht nur vom Quellcode ab, sondern auch von Objektallokationen, warmup, GC, Threading und I/O-Verhalten. Wer nur Framework-Annotationen kennt, kann Produktionsprobleme schwer diagnostizieren.

Heap, GC und Objektallokation

Jedes kleine DTO, jede Stream-Zwischenstruktur und jede Liste erzeugt Objekte. Moderne GCs sind schnell, aber nicht magisch. Ein Reporting-Job, der Millionen Zeilen in eine Liste lädt, kann Heap-Druck erzeugen. Eine API, die in jeder Anfrage große Objektgraphen baut, kann Latenzspitzen verursachen. Die Lösung ist nicht pauschal weniger OOP, sondern bewusstes Datenvolumen, Paging, Streaming und passende Datenstrukturen.

Threads, Visibility und Race Conditions

Mehrere Threads können denselben Zustand sehen. Ohne Synchronisation gibt es keine Garantie, wann ein Thread die Änderung eines anderen sieht. volatile, Locks, atomare Klassen und Concurrent Collections lösen jeweils unterschiedliche Probleme. Virtual Threads ändern daran nichts: Sie machen blockierende I/O-Threads günstiger, aber keine fachlichen Invarianten automatisch sicher.

Virtual Threads richtig einordnen

Virtual Threads helfen bei vielen blockierenden I/O-Operationen, etwa parallelen HTTP-Calls. Sie vergrößern aber nicht automatisch den Datenbank-Connection-Pool, lösen keine Lock-Konflikte und machen CPU-bound Arbeit nicht schneller. Im Enterprise-Design muss deshalb weiterhin begrenzt werden: Timeouts, Bulkheads, Connection Limits und Backpressure bleiben Pflicht.

Deep-Dive-Lesebrille

Kapitel-Checkliste

7. Maven, Module und Enterprise-Build-Governance

Maven, Module und Enterprise-Build-Governance

Build als Architekturvertrag

Ein Maven-Build ist mehr als ein Weg, ein Jar zu erzeugen. Er dokumentiert Modulgrenzen, Java-Version, Plugin-Versionen, Testarten, Dependency-Regeln und Release-Verhalten. In großen Projekten ist ein chaotischer Build ein Architekturproblem: Wenn jedes Modul Versionen selbst wählt, entstehen Konflikte, unklare Verantwortungen und schwer reproduzierbare Fehler.

Parent POM und dependencyManagement

Der Parent POM zentralisiert Versionen und Regeln. dependencyManagement bedeutet nicht, dass eine Dependency automatisch verwendet wird; es legt nur die Version fest, wenn ein Modul diese Dependency nutzt. Genau diese Trennung ist wichtig: Module deklarieren fachlich, was sie brauchen; der Parent kontrolliert die technische Version.

pluginManagement und Lifecycle

Plugins sollten ebenfalls zentral versioniert werden. Compiler, Surefire, Failsafe, Enforcer und Source/Javadoc Plugins gehören nicht zufällig in einzelne Module. Der Maven Lifecycle trennt compile, test, package, verify und install. Integrationstests sollten nicht heimlich in der Unit-Test-Phase laufen. Sonst werden Builds langsam und Entwickler umgehen Tests.

Modulgrenzen

Die Beispielplattform trennt Shared Kernel, Domain, Application, Adapter und Outbox logisch. In der Fallstudie ist es JDK-only als kompilierbares Lab umgesetzt, aber die Struktur entspricht einem späteren Multi-Module-Projekt. Wichtig ist die Richtung: Domain darf nicht von Adapter abhängen. Application darf Ports kennen, aber keine technischen Clients. Adapter dürfen Application/Domain implementieren.

Deep-Dive-Lesebrille

Kapitel-Checkliste

8. Clean Code, Testing und Refactoring-Grundlagen

Clean Code, Testing und Refactoring-Grundlagen

Clean Code im Enterprise-Kontext

Clean Code bedeutet im Enterprise-System nicht schöne Methoden allein. Entscheidend ist, dass fachliche Begriffe sichtbar sind, Verantwortungen getrennt bleiben und Tests Verhalten schützen. Eine Methode mit 20 Zeilen kann schlecht sein, wenn sie Payment, Lager, Rabatt und Persistenz mischt. Eine längere Methode kann akzeptabel sein, wenn sie einen Use Case linear und verständlich orchestriert.

Testpyramide mit Sinn

Unit Tests prüfen Value Objects und Domain-Regeln schnell. Application-Service-Tests prüfen Use Cases mit Fakes. Integrationstests prüfen echte Infrastruktur. Characterization Tests schützen Legacy-Verhalten, bevor Refactoring beginnt. Architekturtests verhindern, dass Adapter plötzlich vom Domain-Kern aufgerufen werden oder Controller direkt Repositories nutzen.

Fakes statt Mock-Theater

Zu viele Mocks testen Interaktionen statt Verhalten. Ein FakeInventoryAdapter mit echtem In-Memory-Zustand zeigt, ob Reservierung, Fehlerfall und Restbestand stimmen. Ein FakePaymentAdapter kann gezielt ablehnen. Diese Tests sind näher am Verhalten und bleiben trotzdem schnell.

Legacy Characterization

Bevor eine Monster-Methode zerlegt wird, muss ihr aktuelles Verhalten sichtbar sein. Characterization Tests sind nicht die endgültige Wahrheit, sondern ein Sicherheitsnetz. Sie dokumentieren sogar falsches Verhalten, damit man es bewusst ändern kann. Erst danach werden Validation, Pricing, Ports und Transaktionsgrenzen extrahiert.

Deep-Dive-Lesebrille

Kapitel-Checkliste

9. Enterprise-Domäne Order, Billing, Payment und Inventory

Enterprise-Domäne Order, Billing, Payment und Inventory

Warum eine durchgehende Domäne wichtig ist

Viele Lernbeispiele bleiben zu klein: ein Controller, ein Service, ein Repository. Enterprise-Systeme sind anders. Eine Bestellung berührt Kunde, Lager, Zahlung, Rechnung, Reporting und Audit. Die fachliche Schwierigkeit liegt in Übergängen, Grenzen und Fehlerfällen. Deshalb nutzt Plattform-Fallstudie eine zusammenhängende Order-to-Invoice-Domäne.

Fachlicher Hauptfluss

Der Kunde sendet einen Auftrag. Das System prüft den Kunden, reserviert Bestand, berechnet Preise, autorisiert Zahlung, speichert die Order, schreibt eine Outbox Message und aktualisiert Reporting später über einen Consumer. Jeder Schritt kann scheitern. Jeder Schritt hat andere Konsistenzanforderungen. Genau daraus entsteht Architektur.

Bounded Contexts und Begriffe

Order kennt Bestellstatus und Positionen. Inventory kennt Bestand und Reservierung. Payment kennt Autorisierung und Ablehnung. Billing kennt Rechnungserzeugung. Reporting kennt Projektionen. Audit kennt Nachvollziehbarkeit. Wenn alle Begriffe in einer Klasse landen, entsteht ein Big Ball of Mud. Wenn sie sauber getrennt sind, kann jedes Modell wachsen.

Szenarien statt nur Klassen

Ein gutes Domain-Modell wird nicht aus Substantiven gebaut, sondern aus Szenarien: Kunde ist gesperrt, Lager fehlt, Payment lehnt ab, Payment war erfolgreich, Outbox wurde geschrieben, Event kam doppelt, Rechnung darf nur einmal entstehen. Diese Szenarien treiben Aggregates, Ports und Tests.

Deep-Dive-Lesebrille

Kapitel-Checkliste

10. DDD, Aggregates, Value Objects und Domain Services

DDD, Aggregates, Value Objects und Domain Services

Aggregate Root als Konsistenzgrenze

Ein Aggregate Root ist nicht einfach eine große Entity. Es ist die Grenze, innerhalb der Invarianten sofort konsistent gehalten werden. Order entscheidet, ob Lines hinzugefügt, Status gewechselt und Events erzeugt werden dürfen. Andere Objekte sollen nicht beliebig in seine interne Liste greifen. Das Aggregate ist der fachliche Schutzraum.

Statusübergänge explizit machen

Statusübergänge sind oft der Kern der Fachlogik. DRAFT -> RESERVED -> PAYMENT_AUTHORIZED -> INVOICED ist nicht nur eine Enum-Reihenfolge. Jeder Übergang hat Bedingungen und Nebenwirkungen. Wenn der Status direkt per Setter geändert wird, ist das Modell praktisch wertlos. Methoden wie markReserved() oder authorizePayment() geben dem Übergang Namen und Regeln.

Domain Services sparsam einsetzen

Ein Domain Service ist sinnvoll, wenn eine Regel mehrere Aggregates oder Value Objects betrifft und nicht natürlich in eines davon gehört. Pricing kann CustomerSegment und OrderLines kombinieren. Aber Domain Services dürfen nicht zu Transaktionsskripten werden. Wenn jede Regel in Services wandert, bleibt ein anämisches Domain-Modell zurück.

Events aus dem Aggregate

Domain Events sollten aus fachlichen Zustandsänderungen entstehen. Wenn Order eine Zahlung autorisiert, kann ein OrderPlaced-Event entstehen. Der Application Service entscheidet dann über Outbox-Persistenz. So bleiben Fachereignis und technische Veröffentlichung getrennt.

Deep-Dive-Lesebrille

Kapitel-Checkliste

11. Ports, Adapter und Application Services

Ports, Adapter und Application Services

Dependency Inversion praktisch

Der Domain- und Application-Kern hängt nicht von Stripe, SAP, Kafka oder einer konkreten Datenbank ab. Er hängt von Ports ab: PaymentPort, InventoryPort, OrderRepository. Adapter implementieren diese Ports. Dadurch kann derselbe Use Case mit Memory-Fakes getestet, später mit REST-Clients betrieben und bei Migrationen umverdrahtet werden.

Application Service als Use-Case-Orchestrierung

Der Application Service ist kein Ort für versteckte Fachlogik. Er prüft den Ablauf, ruft Ports auf, startet die Transaktionsgrenze und koordiniert Persistenz/Outbox. Fachliche Entscheidungen wie Statusübergänge gehören ins Aggregate. Externe Kommunikation gehört hinter Ports. Diese klare Arbeitsteilung verhindert Monster-Services.

JAVA
public Result<PlaceOrderResult, DomainError> placeOrder(PlaceOrderCommand command) {
    return tx.inTransaction(() -> {
        Customer customer = customers.findById(command.customerId()).orElseThrow();
        if (customer.blocked()) return Result.err(new CustomerBlocked(customer.id()));
        Order order = Order.draft(command.orderId(), command.customerId());
        for (Line line : command.lines()) {
            Money price = inventory.reserve(line.productId(), line.quantity()).orElseThrow();
            order.addLine(new OrderLine(line.productId(), line.quantity(), price));
        }
    order.markReserved();
    String txId = payment.authorize(customer.id(), order.total()).orElseThrow();
    order.authorizePayment(txId, clock.instant());
    orders.save(order);
    order.pullEvents().forEach(event -> outbox.add(OutboxMessage.from(command.idempotencyKey(), event)));
    return Result.ok(new PlaceOrderResult(order.id(), order.status().name(), order.total().toString()));
}
);
}

Adapter übersetzen, nicht leaken

Ein Payment-Adapter sollte keine Stripe-Response in den Kern leaken. Er übersetzt externe Fehler in PaymentDeclined oder technische Exceptions. Ein Inventory-Adapter übersetzt Bestandsdaten in fachliche Reservierungsergebnisse. Genau diese Übersetzung schützt den Kern vor externen Datenformen.

Testing-Vorteil

Ports machen Tests einfach. Der Test kann Inventory-Fake, Payment-Fake und InMemoryOutbox einsetzen. Er prüft den Use Case ohne Netzwerk, Datenbank oder Broker. Später ergänzen Integrationstests die echten Adapter. Das ist schneller und stabiler als alles gegen echte Infrastruktur zu testen.

Deep-Dive-Lesebrille

Kapitel-Checkliste

12. JDBC, Repository und Data Mapper

JDBC, Repository und Data Mapper

JDBC als ehrliche Grundlage

JPA abstrahiert viel, aber JDBC zeigt die Wahrheit: SQL wird gesendet, Zeilen kommen zurück, Transaktionen gehören zur Connection, Fehler kommen als SQLExceptions. Wer JDBC versteht, versteht später besser, was JPA versteckt. Repository und Data Mapper helfen, diese technische Form in Domain-Objekte zu übersetzen.

Repository ist kein DAO mit anderem Namen

Ein DAO arbeitet oft tabellennah. Ein Repository wirkt eher collection-artig für Aggregate. OrderRepository.save(order) sollte eine vollständige Aggregate-Persistenz absichern, nicht nur eine Zeile blind schreiben. In einfachen Beispielen sieht beides gleich aus; in echten Systemen entscheidet diese Grenze über Konsistenz.

Data Mapper trennt Domain und Tabellen

Der Data Mapper übersetzt Order in OrderRow und zurück. Im echten Projekt gibt es mehrere Tabellen: order_header, order_line, outbox. Der Mapper verhindert, dass die Domain plötzlich Spaltennamen, Join-Tabellen und SQL-Details kennt. Diese Trennung ist besonders wertvoll bei Legacy-Datenbanken.

JAVA
OrderRow original = database.findOrder("O-1").orElseThrow();
context.manage(original);
context.replace(new OrderRow("O-1", "C-1", "INVOICED", "99.90", original.version()));
for (OrderRow dirty : context.dirtyRows()) {
    database.insertOrUpdate(dirty);
    // analog zu flush()
}

SQL-nahe Fehlerfälle

JDBC-Probleme sind oft unspektakulär: fehlende Indizes, zu große Result Sets, falsche Transaktionsisolation, zu viele Roundtrips, kaputte Batchgrößen. Ein Deep-Dive-Buch muss diese Details nennen, weil sie in Produktion oft wichtiger sind als das Repository-Pattern selbst.

Deep-Dive-Lesebrille

Kapitel-Checkliste

13. JPA/Hibernate Mentalmodell

JPA/Hibernate Mentalmodell

JPA ist ein Persistence Context, nicht nur Annotationen

Viele Probleme entstehen, weil JPA als Magie verstanden wird. Entscheidend ist der Persistence Context: Er hält geladene Entities, stellt Identität sicher, erkennt Änderungen und synchronisiert sie beim Flush mit der Datenbank. Wer nur @Entity und @Transactional sieht, versteht Lazy Loading, Dirty Checking und N+1-Probleme zu spät.

Entity Lifecycle

Eine Entity kann transient, managed, detached oder removed sein. Managed bedeutet: Der Persistence Context verfolgt sie. Detached bedeutet: Es ist nur noch ein normales Objekt. Änderungen an detached Entities werden nicht automatisch persistiert. Fehler entstehen, wenn DTOs, Entities und Transaktionsgrenzen vermischt werden.

Dirty Checking

Dirty Checking ist bequem, aber es versteckt Schreibvorgänge. Eine einfache Setter-Änderung kann später SQL erzeugen. Das ist gut, wenn die Transaktionsgrenze klar ist. Es ist gefährlich, wenn Entities durch mehrere Schichten gereicht werden und irgendwo unbemerkt verändert werden. Deshalb ist Entity-Design auch bei JPA eine Architekturfrage.

JAVA
OrderRow original = database.findOrder("O-1").orElseThrow();
context.manage(original);
context.replace(new OrderRow("O-1", "C-1", "INVOICED", "99.90", original.version()));
for (OrderRow dirty : context.dirtyRows()) {
    database.insertOrUpdate(dirty);
    // analog zu flush()
}

JPA und Domain Model

Es gibt zwei Extreme: reines anämisches JPA-Modell mit Settern überall oder überambitioniertes Domain-Modell, das JPA-Lazy-Loading ignoriert. In Enterprise-Projekten braucht man Pragmatismus: kritische Invarianten in Methoden, Collections geschützt, Fetch-Pläne explizit, DTOs an API-Rändern und klare Transaktionsgrenzen.

Deep-Dive-Lesebrille

Kapitel-Checkliste

14. Persistence Context, Lazy Loading, N+1 und Fetch-Pläne

Persistence Context, Lazy Loading, N+1 und Fetch-Pläne

Identity Map und Wiederverwendung

Innerhalb eines Persistence Context liefert JPA für dieselbe ID dieselbe Entity-Instanz. Das verhindert widersprüchliche Objektkopien und ist Grundlage für Dirty Checking. Aber diese Bequemlichkeit gilt nur innerhalb des Contexts. Nach clear() oder außerhalb der Transaktion ist das Objekt detached.

Lazy Loading verstehen

Lazy Loading verzögert das Laden von Beziehungen. Das spart Daten, kann aber später unerwartete Queries auslösen. Wenn eine API 50 Orders lädt und pro Order lazy die Lines lädt, entstehen 51 Queries. Das ist das N+1-Problem. Es ist kein Anfängerfehler, sondern eine der häufigsten Produktionsursachen für langsame APIs.

N+1 vermeiden

Fetch Join, Entity Graphs, Batch Fetching, DTO-Projektionen und gezielte Query-Modelle sind Werkzeuge. Es gibt nicht eine Lösung für alles. Für Schreib-Use-Cases lädt man Aggregate gezielt. Für Reporting nutzt man oft DTO-Projektionen. Für REST-Listen sollte man nicht komplette Aggregate-Bäume laden.

Fetch-Plan als API-Entscheidung

Jede API braucht Daten in bestimmter Form. Der Fetch-Plan sollte diese Form bewusst unterstützen. Ein Endpunkt GET /orders braucht vielleicht Summary-Daten. GET /orders/{id} braucht Details. Reporting braucht Aggregationen. Wer überall dieselbe Entity lädt, bezahlt entweder zu viel oder lädt zu wenig.

Deep-Dive-Lesebrille

Kapitel-Checkliste

15. Transaktionen und Transaction Boundaries

Transaktionen und Transaction Boundaries

Transaktion als fachliche Grenze

Eine Transaktion ist nicht nur ein technischer Wrapper. Sie definiert, welche Änderungen gemeinsam sichtbar werden. Beim Auftrag sollen Order, Reservierung und Outbox-Eintrag zusammen committed werden. Der tatsächliche Broker-Versand gehört nicht in dieselbe DB-Transaktion. Das ist die Grenze zwischen fachlicher Konsistenz und technischer Integration.

ACID ohne Romantik

Atomicity bedeutet ganz oder gar nicht. Consistency bedeutet, dass Invarianten erhalten bleiben. Isolation bedeutet, dass parallele Transaktionen nicht unkontrolliert ineinanderlaufen. Durability bedeutet, dass committed Änderungen dauerhaft sind. In echten Systemen ist Isolation besonders kritisch, weil Performance und Korrektheit gegeneinander abgewogen werden.

Warum @Transactional allein nicht reicht

@Transactional macht eine Methode nicht automatisch gut. Wenn die Methode externe Systeme aufruft, lange läuft, riesige Datenmengen lädt oder fachliche Grenzen mischt, bleibt sie problematisch. Transaktionsgrenzen müssen kurz, verständlich und fachlich motiviert sein.

JAVA
@Transactional
public void placeOrder(Command command) {
    orderRepository.save(order);
    paymentClient.charge(order.total());
    // externer Aufruf innerhalb der DB-Transaktion
    kafka.send(new OrderPlaced(order.id()));
    // Dual Write: DB und Broker sind nicht atomar
}

Gute Grenze im Order Use Case

Der Use Case reserviert Bestand, speichert die Order und schreibt eine Outbox Message in einer Transaktionsgrenze. Danach übernimmt ein Publisher den Broker-Versand. Dadurch kann ein Broker-Ausfall die fachliche Transaktion nicht mehr zurückdrehen. Gleichzeitig geht das Event nicht verloren, weil es als Datenzeile committed wurde.

Deep-Dive-Lesebrille

Kapitel-Checkliste

16. Locking, Isolation und Konflikte

Locking, Isolation und Konflikte

Lost Update als Grundproblem

Zwei Benutzer laden dieselbe Bestellung. Benutzer A ändert den Status. Benutzer B ändert auf Basis des alten Zustands ebenfalls. Ohne Schutz überschreibt B die Änderung von A. Das ist ein Lost Update. In kleinen Tests fällt es selten auf; in echten Systemen ist es Alltag.

Optimistic Locking

Optimistic Locking geht davon aus, dass Konflikte selten sind. Eine Versionsspalte wird beim Speichern geprüft. Wenn die Version nicht mehr passt, schlägt das Speichern fehl. Das ist ideal für viele Business-Objekte, die selten gleichzeitig geändert werden. Der Konflikt muss aber fachlich behandelt werden: neu laden, Benutzer informieren oder kontrolliert retryen.

JAVA
OrderRow userA = store.load("O-1");
OrderRow userB = store.load("O-1");
store.save(userA.withStatus("RESERVED"), userA.version());
store.save(userB.withStatus("CANCELLED"), userB.version());
// wirft OptimisticLockException

Pessimistic Locking

Pessimistic Locking sperrt früh. Das ist sinnvoll, wenn Konflikte häufig oder teuer sind, zum Beispiel bei knappen Ressourcen. Aber Sperren müssen kurz und in konsistenter Reihenfolge gehalten werden. Sonst entstehen Deadlocks. Pessimistic Locking ist kein Allheilmittel, sondern ein bewusstes Werkzeug.

Isolation Levels praktisch

Read Committed verhindert Dirty Reads, aber nicht automatisch Non-repeatable Reads oder Phantoms. Repeatable Read und Serializable erhöhen Konsistenz, können aber mehr Locks und weniger Durchsatz bedeuten. In Enterprise-Systemen muss Isolation pro Use Case bewertet werden: Reporting, Buchung, Lagerreservierung und Suche haben unterschiedliche Anforderungen.

Deep-Dive-Lesebrille

Kapitel-Checkliste

17. Outbox, Idempotenz und Konsistenz

Outbox, Idempotenz und Konsistenz

Das Dual-Write-Problem

Wenn ein Use Case erst die Datenbank schreibt und danach Kafka/JMS sendet, gibt es einen Moment zwischen beiden Aktionen. Stürzt der Prozess in diesem Moment ab, ist die Order gespeichert, aber kein Event gesendet. Sendet er zuerst und die DB rollt zurück, existiert ein Event für eine nicht gespeicherte Order. Genau das ist das Dual-Write-Problem.

Transactional Outbox

Die Outbox speichert das Event als Datenzeile in derselben Transaktion wie die Business-Änderung. Ein separater Publisher liest pending Events und sendet sie. Dadurch wird aus einem atomaren DB-Problem ein kontrollierbarer Integrationsprozess mit Retry, Status und Monitoring.

Crash-Fälle

Outbox muss Crash-Fälle ernst nehmen: Crash vor Commit, Crash nach Commit vor Publish, Crash nach Publish vor Statusupdate. Der letzte Fall erzeugt potenziell doppelte Events. Deshalb braucht die Consumer-Seite Idempotenz. Outbox ohne idempotente Consumer ist nur die halbe Lösung.

JAVA
var result = service.placeOrder(command);
assertTrue(result.isOk());
assertEquals(1, outbox.all().size());
publisher.publishBatch();
OutboxMessage published = outbox.all().get(0);
assertTrue(published.published());
consumer.consume(published);
consumer.consume(published);
// doppelte Lieferung
assertEquals(1, consumer.projections().size());

Idempotenter Consumer

Ein Consumer speichert verarbeitete Message-IDs oder fachliche Idempotency Keys. Kommt dieselbe Nachricht erneut, wird keine zweite Rechnung erzeugt und kein Reporting doppelt erhöht. Die Idempotenzprüfung muss vor der Nebenwirkung passieren und selbst konsistent gespeichert werden.

Betrieb der Outbox

Outbox ist nicht nur Code. Es braucht Indizes auf Status und Zeitpunkt, Limits pro Batch, Backoff bei Fehlern, Dead-Letter-Strategie, Cleanup alter Events, Metriken für pending/failed Messages und Alarme. Sonst wird Outbox selbst zur Produktionsstörung.

Deep-Dive-Lesebrille

Kapitel-Checkliste

18. Zusammenhängende Plattform-Fallstudie

Zusammenhängende Rebuild-1-Fallstudie

End-to-End lesen

Die Fallstudie verbindet alle Grundlagen. Money und IDs zeigen Value Objects. Order zeigt Aggregate und Statusübergänge. OrderApplicationService zeigt Use-Case-Orchestrierung. Ports/Adapter machen Infrastruktur austauschbar. Outbox macht Event-Veröffentlichung robust. Persistence Context und Locking zeigen Datenzugriff unter der Oberfläche.

Was am Code bewusst nicht gemacht wurde

Das Lab nutzt kein Spring Boot, kein Hibernate und keinen Broker. Das ist Absicht. Frameworks würden viele Mechanismen verdecken. Die Fallstudie zeigt zuerst die Mechanik. Spätere Vertiefungen können dieselbe Domäne in Spring, Jakarta, Quarkus und Micronaut übertragen. Dann versteht man, welche Probleme das Framework löst und welche nicht.

Wie du das Lab lesen solltest

Beginne mit Money, DomainError und Result. Lies danach Order, dann OrderApplicationService, dann die Ports und Adapter. Danach Outbox und Persistence. Zum Schluss den Test Runner. Die Tests sind nicht nur Prüfung, sondern Leseführung: Sie zeigen die wichtigsten Szenarien.

Nächste inhaltliche Vertiefung

Nach Plattform-Fallstudie ist die Basis stabiler. Der nächste echte Deep-Dive sollte nicht sofort alle neuen Frameworks anfassen, sondern einzelne harte Themen weiter vertiefen: Transaktionen 4C.1, Locking 4C.2, Outbox 4C.3 und Produktionssicht 4C.4. Erst danach lohnt REST, Security und Messaging als neuer Block.

Deep-Dive-Lesebrille

Kapitel-Checkliste

19. Code-Walkthrough: Shared Kernel als Fundament

Warum Shared Kernel klein bleiben muss

Ein Shared Kernel ist verführerisch. Sobald mehrere Module dieselben Typen brauchen, wächst er schnell zu einem Sammelbecken für alles: Utility-Klassen, globale Konstanten, technische Hilfsfunktionen, Datumsformatierer, Security-Helfer und halbe Infrastruktur. Genau das ist gefährlich. Ein Shared Kernel sollte nur sehr stabile, fachlich breite Konzepte enthalten: IDs, Geld, Result-Typen, Fehlerfamilien und kleine Basiskonzepte. Jede Klasse im Shared Kernel ist eine Kopplungsentscheidung.

Im Fallstudien-Code enthält der Shared Kernel bewusst nur wenige Typen. Money ist fachlich stabil, weil fast alle Kontexte Geldbeträge brauchen. OrderId, CustomerId und ProductId sind ebenfalls stabil, aber sie bleiben klein. Result und DomainError sind didaktisch zentral, weil sie fachliche Fehlerflüsse sichtbar machen. Nicht im Shared Kernel liegen dagegen Repositories, Payment-Details oder Outbox-Implementierungen.

Code-Leseweg für Money

Lies Money nicht als Record-Beispiel, sondern als Enterprise-Schutzmechanismus. Der Konstruktor normalisiert die Skala. add prüft die Währung. multiply erzeugt ein neues Objekt. Dadurch wird verhindert, dass eine Bestellung versehentlich EUR und USD mischt oder Rundung an beliebiger Stelle passiert. In echten Systemen wird daraus häufig noch mehr: Steuerlogik, Währungsumrechnung, Rundungsmodi pro Land und Auditierbarkeit.

JAVA
Money subtotal = Money.eur("19.90").multiply(2);
Money shipping = Money.eur("4.90");
Money total = subtotal.add(shipping);

Die wichtige Frage ist nicht, ob dieser Code kurz ist. Die wichtige Frage ist: Wo kann ein ungültiger Geldzustand entstehen? Bei einem guten Value Object ist die Antwort: möglichst nirgendwo.

Code-Leseweg für Result und DomainError

Result macht erwartbare Fehler explizit. Das zwingt den Application Service, fachliche Ablehnungen zu behandeln. DomainError ist sealed, damit neue Fehlerarten sichtbar werden. Diese Kombination ist besonders hilfreich, wenn später REST Problem Details, UI-Meldungen oder Audit Events entstehen.

Ein häufiger Fehler wäre, alle Fehler in String errorMessage zu pressen. Das ist kurzfristig einfach, aber langfristig schlecht: Die API kann dann nicht sauber zwischen PAYMENT_DECLINED, CUSTOMER_BLOCKED und INVENTORY_UNAVAILABLE unterscheiden.

Produktionshinweise

20. Code-Walkthrough: Order Aggregate wirklich lesen

Aggregate ist Verhalten, nicht Datencontainer

Order enthält private Felder, kontrollierte Methoden und Domain Events. Das ist absichtlich. Eine Bestellung darf nicht beliebig von außen in jeden Zustand gesetzt werden. Ein Setter wie setStatus(OrderStatus.INVOICED) wäre bequem, aber er zerstört die Fachlichkeit. Niemand sähe mehr, ob Lager reserviert, Payment autorisiert oder eine Rechnung erzeugt wurde.

Ein Aggregate schützt also nicht nur Daten, sondern die erlaubten Übergänge. addLine, markReserved, authorizePayment und invoice sind fachliche Operationen. Sie geben dem Code Sprache.

Schlechte Alternative

JAVA
order.setStatus("PAID");
order.setTotal(total);
order.getLines().add(line);
orderRepository.save(order);

Dieser Code sieht harmlos aus, aber jede Zeile umgeht Fachlogik. Es gibt keine Garantie, dass Lines gültig sind, Total korrekt berechnet wurde oder der Statusübergang erlaubt ist.

Bessere Alternative

JAVA
order.addLine(new OrderLine(productId, quantity, unitPrice));
order.markReserved();
order.authorizePayment(transactionId, now);

Hier sieht man die fachliche Geschichte. Ein Test kann genau diese Geschichte prüfen. Ein Audit kann daran anschließen. Ein Domain Event kann beim Übergang entstehen.

Wann Aggregate zu groß werden

Ein Aggregate darf nicht alle Enterprise-Probleme aufnehmen. Wenn Order plötzlich Payment-HTTP-Clients, SQL, Kafka und Reporting kennt, ist es kein Aggregate mehr, sondern ein Big Ball of Mud. Payment-Autorisierung ist ein Port im Application Service. Reporting passiert später über Events. Das Aggregate kennt nur das fachliche Ergebnis.

Produktionshinweise

21. Code-Walkthrough: Application Service ohne Monster-Methode

Was der Application Service darf

Ein Application Service darf orchestrieren: laden, prüfen, Ports aufrufen, Transaktionsgrenze nutzen, Aggregate speichern und Outbox schreiben. Er sollte aber keine tiefen fachlichen Berechnungen verstecken. Wenn Rabattlogik, Steuerlogik, Statuslogik und Payment-Regeln im Application Service landen, entsteht wieder eine Monster-Methode.

Die Fallstudie-Code ist bewusst linear lesbar. Man sieht den Use Case in Reihenfolge: Kunde laden, Sperre prüfen, Order erzeugen, Lager reservieren, Payment autorisieren, Order speichern, Outbox schreiben, Audit ergänzen.

Warum Linearität manchmal besser ist als künstliche Abstraktion

Nicht jede Methode muss sofort extrahiert werden. Ein Use Case darf als Geschichte lesbar bleiben. Zu viele winzige private Methoden mit technischen Namen können genauso schaden wie eine Monster-Methode. Die Frage lautet: Wird eine fachliche Verantwortung sichtbar oder nur Code versteckt?

Fehlerfall-Matrix

Schritt Fehler Ergebnis
Kunde laden unbekannter Kunde technischer/validierender Abbruch
Kunde prüfen Kunde gesperrt CustomerBlocked
Lager reservieren Bestand fehlt InventoryUnavailable
Payment Zahlung abgelehnt PaymentDeclined
Speichern Optimistic Lock ConcurrentModification oder technische Exception
Publisher Broker down Business-Transaktion bleibt committed, Outbox pending

Produktionshinweise

22. Deep Dive: Persistenz vom SQL bis zum Persistence Context

Warum Data Mapper im Lernbuch wichtig ist

Frameworks verstecken viel. Ein Data Mapper zeigt die Trennung: Domain-Objekt hier, relationale Zeile dort. Diese Trennung ist für Legacy-Systeme entscheidend, weil Datenbanken oft älter, unsauberer und stabiler sind als die Anwendung. Man kann nicht immer die perfekte Domain-Struktur direkt in Tabellen gießen.

Relationale Realität

Eine Order ist fachlich ein Aggregate. Relational kann sie aus orders, order_lines, order_audit, outbox_messages und weiteren Tabellen bestehen. Ein Repository muss entscheiden, was zum Aggregate gehört und welche Daten nur Projektion oder Integration sind. Ein JPA-Mapping ist deshalb nie nur Annotation; es ist eine Modellentscheidung.

Persistence Context als Denkmodell

Der Persistence Context ist eine Arbeitsmappe. Er merkt sich geladene Objekte, vergleicht Zustände und schreibt Änderungen beim Flush. Das ist bequem, aber auch gefährlich: Ein Setter irgendwo im Code kann später SQL erzeugen. Deshalb müssen Transaktionsgrenzen, Entity-Lebensdauer und DTO-Grenzen klar sein.

Typische Produktionsfehler

  1. Lazy Loading außerhalb der Transaktion.
  2. N+1 Queries in Listen-Endpunkten.
  3. Zu große Object Graphs bei REST-Responses.
  4. Dirty Checking schreibt unerwartete Änderungen.
  5. Detached Entities werden als managed angenommen.
  6. equals/hashCode von Entities hängt an veränderlicher ID.
  7. Batch-Jobs laden Millionen Entities statt Streaming/Paging.

Checkliste für Repository-Methoden

23. Deep Dive: Transaktionsfehler als Szenarien

Szenario 1: Payment innerhalb der DB-Transaktion

Ein Team schreibt: Order speichern, Payment aufrufen, Event senden - alles in einer @Transactional-Methode. Das wirkt atomar, ist es aber nicht. Die Datenbanktransaktion kann den externen Payment-Anbieter nicht zurückrollen. Wenn Payment erfolgreich ist und danach die DB scheitert, wurde Geld autorisiert, aber keine Order gespeichert.

Szenario 2: Event direkt nach Commit senden

Order wird committed, danach sendet der Prozess ein Event. Stürzt der Prozess direkt nach dem Commit ab, wird kein Event gesendet. Reporting, Billing oder Notification erfahren nichts. Dieses Problem wird nicht durch mehr try/catch gelöst, weil der Prozess wirklich weg sein kann.

Szenario 3: Outbox Publisher sendet, stürzt vor Statusupdate ab

Der Publisher sendet das Event erfolgreich, stürzt aber vor markPublished ab. Beim Neustart sendet er erneut. Genau deshalb muss der Consumer idempotent sein. Outbox macht Lieferung zuverlässig, aber nicht automatisch exakt-einmal im fachlichen Sinn.

Gute Architekturantwort

Was Tests prüfen müssen

24. Deep Dive: Locking und Isolation anhand echter Konflikte

Konflikt: Bestand parallel reservieren

Zwei Bestellungen reservieren gleichzeitig den letzten Artikel. Ohne Locking oder atomare Datenbankbedingung können beide erfolgreich sein. Das Problem liegt nicht im Java-Code allein, sondern in der Grenze zwischen Application Service, Datenbankisolation und fachlicher Regel.

Optimistische Lösung

Jede Bestandszeile hat eine Version. Beim Speichern wird where id=? and version=? genutzt. Wenn keine Zeile aktualisiert wurde, gab es einen Konflikt. Der Use Case kann neu laden oder dem Benutzer mitteilen, dass der Bestand nicht mehr verfügbar ist.

Pessimistische Lösung

Der Bestand wird vor der Änderung gesperrt. Das verhindert parallele Änderung, kann aber Wartezeiten und Deadlocks erzeugen. Wichtig sind kurze Transaktionen und konsistente Lock-Reihenfolge.

Deadlock verstehen

Deadlock entsteht, wenn Transaktion A Produkt 1 hält und Produkt 2 will, während Transaktion B Produkt 2 hält und Produkt 1 will. Die Datenbank bricht eine Transaktion ab. Die Anwendung muss das als retrybaren technischen Konflikt verstehen, nicht als unbekannten 500-Fehler.

Produktionshinweise

25. Deep Dive: Testing als Leseführung

Tests erklären Architektur

Tests sind nicht nur Qualitätskontrolle. Gute Tests zeigen, wie das System benutzt werden soll. Der Rebuild1TestRunner ist deshalb bewusst einfach: Er zeigt Geld-Immutability, Order-Flow, Duplicate Command, idempotenten Consumer, Optimistic Lock und Legacy Characterization. Jeder Test ist ein Kapitel in Miniaturform.

Test Data Builder statt unlesbarer Objektkonstruktion

Wenn Tests zehn Parameter pro Objekt bauen müssen, liest niemand mehr die Absicht. In späteren Vertiefungen sollte ein TestData-Modul entstehen: validCustomer(), validOrderCommand(), vipCustomer(), blockedCustomer(), outOfStockInventory(). Dadurch kann ein Test fachlich sprechen.

Produktionshinweise

26. Fehlerkatalog Grundlagen und Outbox

Java und Objektmodell

Collections und Streams

JPA/Persistenz

Transaktionen

Outbox

27. Lernaufgaben zur Plattform-Fallstudie

Aufgabe 1: Money erweitern

Erweitere Money um eine Methode allocate(int parts), die einen Betrag sauber auf mehrere Teile verteilt. Achte auf Rundungsreste. Schreibe Tests für 10.00 EUR auf 3 Teile.

Aufgabe 2: Order-Storno fachlich modellieren

Füge cancel() im Aggregate hinzu. Definiere, in welchen Status eine Stornierung erlaubt ist. Erzeuge ein Domain Event OrderCancelled. Passe Tests an.

Aufgabe 3: Outbox-Retry simulieren

Baue einen Publisher, der beim ersten Publish fehlschlägt und beim zweiten erfolgreich ist. Prüfe, dass attempts erhöht wird und die Nachricht danach published ist.

Aufgabe 4: Optimistic Lock fachlich behandeln

Ersetze die direkte Exception durch ein DomainError.ConcurrentModification. Diskutiere, ob das im Repository oder Application Service passieren sollte.

Aufgabe 5: N+1 sichtbar machen

Simuliere ein Repository, das Order-Liste und Lines einzeln lädt. Zähle Queries. Baue danach eine Batch-Variante.

Aufgabe 6: Legacy Refactoring planen

Zerlege LegacyOrderProcessor auf Papier in Validation, Pricing, Customer Check, Limit Check und Result Mapping. Schreibe zuerst Characterization Tests, dann extrahiere Schritt für Schritt.

28. Entscheidungslandkarte der Plattform-Fallstudie

Wann nutze ich welches Konzept?

Problem Werkzeug Vorsicht
Fachlicher Wert ohne Identität Value Object immutable und validiert halten
Erwartbarer Business-Fehler Result + DomainError technische Fehler nicht verstecken
Statusübergänge schützen Aggregate Root keine public Setter für Invarianten
Fremdsystem anbinden Port/Adapter externe DTOs nicht leaken
Datenbankzugriff kapseln Repository Datenvolumen und Fetch-Plan beachten
Relationale Form trennen Data Mapper Mapping testen
Event nach Commit senden Outbox Consumer idempotent machen
parallele Änderung erkennen Optimistic Lock Konflikt fachlich behandeln
knappe Ressource schützen Pessimistic Lock Deadlocks und Wartezeiten beachten
Legacy sicher ändern Characterization Test falsches Verhalten bewusst dokumentieren

Was diese Fallstudie bewusst vorbereitet

Diese Fallstudie bereitet die nächsten Vertiefungskapitel vor. Er ist die konsolidierte Grundlage, damit Kapitel bis 4C.4 wirklich tief werden können. Danach sind REST, Security, Messaging und Frameworks sinnvoller, weil die Kernprobleme bereits stabil verstanden sind.

⌂ Cockpit