Enterprise Java 11, 17 und 21 - Deep Dive V3

Großes Arbeitsbuch mit Grundlagen, Architektur, Datenzugriff, Transaktionen, Messaging, Security, Legacy-Refactoring, JVM/Performance und Framework-Vergleich. Code-Labs sind separat gesammelt und direkt verlinkt.

Code-LabsEchte Quelldateien, nach Thema sortiert.iPhone/SafariRobuste mobile Lesefassung.PDFVollständige Druckfassung.
Grundlagen & KonsistenzDomänenmodell, Transaktionsgrenzen, Isolation und konkurrierende Änderungen.
01. Grundlagen bis Outbox - integrierter Deep Dive

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.

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

  • Praxisproblem: Ein Entwickler ergänzt Rabattlogik und addiert versehentlich Beträge unterschiedlicher Währung.
  • Typisches Missverständnis: BigDecimal reicht, weil Geld ja nur eine Zahl ist.
  • Bessere Denkweise: Geld ist ein fachliches Konzept mit Betrag, Währung, Rundung und Invarianten.
  • Produktionshinweis: Geldwerte nie als double modellieren; Rundung und Währung gehören zum Modell.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Eine Entity wird in ein HashSet gelegt, danach bekommt sie eine Datenbank-ID, und der Eintrag ist nicht mehr zuverlässig auffindbar.
  • Typisches Missverständnis: equals/hashCode kann man einfach von der IDE generieren lassen.
  • Bessere Denkweise: Gleichheit ist eine Architekturentscheidung, nicht nur Boilerplate.
  • Produktionshinweis: Für JPA-Entities equals/hashCode bewusst und konservativ modellieren.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Team ersetzt alle Klassen durch Records und wundert sich, dass JPA, Lazy Loading und Statusübergänge schwieriger werden.
  • Typisches Missverständnis: Record bedeutet automatisch gutes Domain-Modell.
  • Bessere Denkweise: Record ist ein Werkzeug für unveränderliche Datenformen; Aggregates brauchen oft Verhalten und kontrollierte Mutation.
  • Produktionshinweis: Records bevorzugt für DTOs, IDs, Commands, Events und Value Objects einsetzen.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Team nutzt HashSet für Entities und bekommt sporadische Duplikate oder nicht auffindbare Elemente.
  • Typisches Missverständnis: Set ist immer besser, weil es Duplikate verhindert.
  • Bessere Denkweise: Der Collection-Typ muss die fachliche Regel ausdrücken und mit equals/hashCode zusammenpassen.
  • Produktionshinweis: Mutable Entities nicht leichtfertig als HashMap-Key oder HashSet-Element einsetzen.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Payment wird abgelehnt und erscheint als HTTP 500, obwohl es ein fachlicher Zustand ist.
  • Typisches Missverständnis: Jede Exception ist ein Fehler, also 500.
  • Bessere Denkweise: Fachliche Ablehnungen sind erwartbare Ergebnisse und brauchen explizite Codes.
  • Produktionshinweis: API-Fehlerkatalog mit DomainError-Codes synchron halten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Eine API wird auf Virtual Threads umgestellt und überlastet danach die Datenbank mit mehr parallelen Queries.
  • Typisches Missverständnis: Virtual Threads machen alles schneller.
  • Bessere Denkweise: Virtual Threads reduzieren Thread-Kosten, aber Ressourcenlimits bleiben real.
  • Produktionshinweis: Bei DB-Zugriffen Connection Pool, Query-Zeit und Locking gemeinsam betrachten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Modul zieht unbemerkt eine alte transitive JSON-Bibliothek und produziert Laufzeitfehler.
  • Typisches Missverständnis: Wenn es kompiliert, ist der Build gut.
  • Bessere Denkweise: Build-Governance verhindert Drift, Dependency-Hölle und unklare Release-Zustände.
  • Produktionshinweis: Enforcer, dependency convergence und zentrale Plugin-Versionen früh etablieren.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Refactoring verändert Rabattverhalten, ohne dass es jemand merkt.
  • Typisches Missverständnis: Tests sind erst nach dem Refactoring wichtig.
  • Bessere Denkweise: Tests müssen vor riskanten Änderungen das aktuelle Verhalten einfrieren.
  • Produktionshinweis: Characterization Tests mit Namen schreiben, die Verhalten und bekannte Schwächen erklären.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Team modelliert nur Tabellen und merkt spät, dass Statusübergänge nirgends geschützt sind.
  • Typisches Missverständnis: Domain Model ist eine 1:1-Abbildung der Datenbank.
  • Bessere Denkweise: Domain Model schützt fachliche Invarianten; Datenbank ist Persistenzform.
  • Produktionshinweis: Szenario-Tests aus echten Geschäftsprozessen ableiten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Eine Bestellung kann nach Rechnungserzeugung erneut storniert werden, weil jeder Code den Status setzen darf.
  • Typisches Missverständnis: Ein Enum reicht für Statuslogik.
  • Bessere Denkweise: Statusübergänge brauchen Methoden, Regeln, Tests und manchmal Events.
  • Produktionshinweis: Keine public Setter für kritische Aggregate-Zustände.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Payment-Anbieter ändert sein JSON und plötzlich muss Domain-Code angepasst werden.
  • Typisches Missverständnis: Adapter sind nur technische Wrapper.
  • Bessere Denkweise: Adapter sind Anti-Korruptionsschichten; sie übersetzen Sprache und Fehler.
  • Produktionshinweis: Externe DTOs nie direkt im Domain-Kern verwenden.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Eine API lädt 100000 Orders in den Speicher, weil Repository keine Pagination anbietet.
  • Typisches Missverständnis: Repository versteckt Datenzugriff, also ist Performance egal.
  • Bessere Denkweise: Repository schützt Fachlogik, aber muss Datenvolumen und SQL-Verhalten respektieren.
  • Produktionshinweis: Pagination, Indizes und Fetch-Pläne im Repository-Design mitdenken.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Controller gibt eine Entity zurück und löst außerhalb der Transaktion Lazy Loading aus.
  • Typisches Missverständnis: JPA lädt alles, was ich im Objektgraphen sehe.
  • Bessere Denkweise: JPA lädt nach Fetch-Plan und Transaktionskontext; Objektgraph ist nicht automatisch verfügbar.
  • Produktionshinweis: Entities nicht direkt als REST-DTOs exponieren.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Eine Order-Liste ist lokal schnell, in Produktion mit echten Daten aber langsam wegen 5000 Extraqueries.
  • Typisches Missverständnis: Lazy Loading ist immer besser, weil es Daten spart.
  • Bessere Denkweise: Lazy Loading ist ein Werkzeug; der Fetch-Plan muss pro Use Case definiert werden.
  • Produktionshinweis: SQL-Query-Anzahl in Tests und Monitoring beobachten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Die Zahlung wurde extern belastet, danach rollt die DB-Transaktion zurück.
  • Typisches Missverständnis: Alles in eine große Transaktion packen ist sicher.
  • Bessere Denkweise: Externe Nebenwirkungen und DB-Transaktionen brauchen klare Entkopplung.
  • Produktionshinweis: Keine langsamen externen Calls innerhalb langer DB-Transaktionen.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Zwei parallele Reservierungen verkaufen denselben letzten Artikel.
  • Typisches Missverständnis: Die Datenbank wird es schon regeln.
  • Bessere Denkweise: Die Datenbank bietet Mechanismen, aber die Anwendung muss Konflikte fachlich modellieren.
  • Produktionshinweis: Konfliktmetriken und Retry-Raten beobachten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Nach Broker-Ausfall wachsen Pending Events unbemerkt über Tage.
  • Typisches Missverständnis: Outbox löst Messaging automatisch vollständig.
  • Bessere Denkweise: Outbox macht Ereignisse persistent; Betrieb, Monitoring und Idempotenz bleiben Pflicht.
  • Produktionshinweis: Pending-Count, ältestes Pending-Event und Retry-Rate alarmieren.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Praxisproblem: Ein Lernbuch zeigt nur Snippets; im Projekt weiß man trotzdem nicht, wie alles zusammenhängt.
  • Typisches Missverständnis: Viele kleine Beispiele ergeben automatisch Verständnis.
  • Bessere Denkweise: Ein zusammenhängender Flow zeigt Grenzen, Abhängigkeiten und Fehlerfälle.
  • Produktionshinweis: Beim Lernen immer Code, Diagramm, Test und Produktionshinweis zusammen betrachten.

Kapitel-Checkliste

  • Kann ich das Praxisproblem in eigenen Worten erklären?
  • Habe ich die schlechte Lösung verstanden?
  • Kann ich die bessere Lösung im Code wiederfinden?
  • Kenne ich mindestens einen Produktionsfehler zu diesem Thema?
  • Gibt es im Code oder Test ein Beispiel dazu?

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

  • Shared Kernel nicht als Utility-Müllhalde verwenden.
  • Value Objects versionieren, wenn sie über Servicegrenzen serialisiert werden.
  • Fehlercodes stabil halten; Texte dürfen sich ändern, Codes nicht leichtfertig.
  • Keine technischen Exceptions als DomainError tarnen.

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

  • Aggregate-Methoden nach Fachaktionen benennen, nicht nach technischen Settern.
  • Interne Collections nie direkt modifizierbar zurückgeben.
  • Domain Events aus fachlichen Übergängen erzeugen, nicht aus Controller-Code.
  • Große Aggregates regelmäßig auf Konsistenzgrenzen prüfen.

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

  • Application Services klein nach Use Case schneiden, nicht nach technischen Entitäten.
  • Externe Ports immer mit Timeouts und Fehlerklassifizierung betreiben.
  • Lange Transaktionen vermeiden.
  • Outbox in derselben Transaktion schreiben wie den Business-Zustand.

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

  • Welche Datenmenge kann maximal zurückkommen?
  • Ist der Fetch-Plan explizit?
  • Braucht der Use Case ein Aggregate oder nur eine Projektion?
  • Gibt es passende Indizes?
  • Ist die Methode in einer klaren Transaktionsgrenze?
  • Gibt es Tests für Query-Anzahl oder N+1-Risiko?

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

  • Business-Zustand und Outbox in einer Transaktion schreiben.
  • Publisher separat betreiben.
  • Consumer idempotent machen.
  • Retry und Dead Letter kontrollieren.
  • Monitoring auf Pending, Failed und Oldest Event einrichten.

Was Tests prüfen müssen

  • Order gespeichert und Outbox vorhanden.
  • Publisher kann Broker-Ausfall überstehen.
  • Consumer verarbeitet doppelte Nachricht nur einmal.
  • Optimistic Lock Konflikt wird sichtbar.
  • Retry wird nur bei technischen Fehlern gemacht, nicht bei fachlichen Ablehnungen.

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

  • Lock-Reihenfolge definieren, z. B. ProductIds sortiert sperren.
  • Transaktionen kurz halten.
  • Retry mit Backoff und Obergrenze.
  • Konflikte messen, nicht nur loggen.
  • Fachliche Konflikte nicht blind automatisch retryen.

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

  • Tests sollen Fehlerklassen unterscheiden.
  • Tests sollen nicht nur happy path prüfen.
  • Fakes brauchen echtes Verhalten, nicht nur Rückgabewerte.
  • Testnamen müssen fachliche Geschichten erzählen.

26. Fehlerkatalog Grundlagen und Outbox

Java und Objektmodell

  • Mutable Key in HashMap.
  • Entity mit veränderlicher ID in HashSet.
  • BigDecimal ohne Skala/Rundungskonzept.
  • double für Geld.
  • Interne Collections direkt zurückgegeben.
  • Records mit mutablen Komponenten ohne Kopie.

Collections und Streams

  • Stream mit versteckten Nebenwirkungen.
  • Optional als Entity-Feld.
  • List statt fachlich benötigtem Set oder umgekehrt.
  • Map<String,Object> als dauerhaftes Domain-Modell.
  • Gruppierung großer Datenmengen im Speicher statt in DB/Stream.

JPA/Persistenz

  • Entity direkt als REST-Response.
  • Lazy Loading außerhalb Transaktion.
  • N+1 in Listen-API.
  • Fetch Join mit Pagination falsch kombiniert.
  • Dirty Checking erzeugt unerwartete Updates.
  • Detached Entity wird wiederverwendet, als wäre sie managed.

Transaktionen

  • Externe API innerhalb langer Transaktion.
  • Event direkt an Broker ohne Outbox.
  • Zu große Transaktionsgrenze.
  • Retry bei fachlichen Fehlern.
  • Keine Trennung zwischen technischer Exception und Business-Ablehnung.

Outbox

  • Keine Idempotenz im Consumer.
  • Keine Indizes auf Pending-Status.
  • Keine Dead-Letter-Strategie.
  • Kein Monitoring auf ältestes Pending Event.
  • Publisher batcht unbegrenzt.
  • Cleanup löscht Events ohne Audit-Anforderung zu prüfen.

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.

Code-Labs zu diesem Kapitel

02. Transaktionen, Grenzen und ACID

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.

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:

  • E-Mail wurde bereits gesendet.
  • Kafka/JMS-Message wurde bereits publiziert.
  • Payment-Provider hat eine Autorisierung erhalten.
  • Datei wurde in ein externes Storage geschrieben.
  • Remote-System hat einen Zustand geaendert.

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:

  • Man sieht, was vor Commit nur geplant ist.
  • Man sieht, dass Rollback staged changes verwirft.
  • Man sieht, dass Broker-Publish nicht automatisch staged ist.
  • Man kann Crash-Szenarien reproduzierbar testen.

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:

  • Transaktionen kurz halten.
  • Keine langsamen Remote Calls innerhalb einer DB-Transaktion ausfuehren.
  • Timeouts explizit konfigurieren.
  • Rollback-Ursachen loggen, aber keine sensiblen Daten leaken.
  • Correlation IDs in Use Case, Outbox und Consumer mitfuehren.
  • Outbox-Tabellen indexieren: Status, CreatedAt, Attempts.
  • Retry nicht unendlich und nicht ohne Backoff.
  • Dead-Letter- oder Failed-Status fachlich sichtbar machen.
  • Metriken fuer pending Outbox, failed Outbox und Publish-Latenz erfassen.

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:

  • Welche Methode definiert die fachliche Transaction Boundary?
  • Welche Aenderungen muessen gemeinsam committed werden?
  • Welche Nebenwirkungen sind nicht rollbackfaehig?
  • Wo entsteht ein Dual-Write-Risiko?
  • Wird ein Event direkt publiziert oder zuerst in Outbox geschrieben?
  • Sind fachliche Fehler von technischen Fehlern getrennt?
  • Gibt es Tests fuer Rollback, Crash vor Commit und Publish nach Commit?
  • Sind Logs, Correlation IDs und Outbox-Metriken vorgesehen?

Code-Labs zu diesem Kapitel

03. Locking, Isolation und Deadlocks

Kapitelkompass

Nebenläufigkeit als Zustandsproblem lesen

Isolation entscheidet, welche Zwischenzustände andere Transaktionen sehen und wie Konflikte behandelt werden.

LesenAusgangspunktKonfliktEinordnenErkennenAbsichernNeu versuchenErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Anomalien unterscheiden
  • Optimistic Locking einsetzen
  • Deadlocks reproduzieren
  • Retry sicher begrenzen

Praxisfall

Zwei Bearbeiter aktualisieren dieselbe Bestellung in unterschiedlicher Reihenfolge.

Entscheidung

Optimistisch bei seltenen Konflikten; pessimistisch nur bei begründeter hoher Konkurrenz.

!

Typisches Risiko

Blindes Retry kann fachliche Duplikate erzeugen und Deadlocks nur zeitlich verschieben.

1. Warum Locking ein fachliches Thema ist

Locking wird in Projekten oft zu spät betrachtet, weil es nach Datenbankdetail klingt. In Wahrheit entscheidet Locking darüber, ob fachliche Zusagen stimmen: ob ein Lagerbestand wirklich reserviert ist, ob eine Rechnung nur einmal erzeugt wird, ob eine Zahlung nicht doppelt bestätigt wird und ob zwei Support-Mitarbeiter dieselbe Bestellung gleichzeitig widersprüchlich ändern können.

In Kapitel ging es um Transaktionsgrenzen. Kapitel geht eine Ebene tiefer: Was passiert, wenn mehrere Transaktionen gleichzeitig auf dieselben Daten zugreifen? Ohne klares Modell entstehen Fehler, die im Unit Test selten auffallen, im Betrieb aber sehr teuer werden.

Merksatz
Eine Transaktion schützt nicht automatisch vor falscher Konkurrenzlogik. Sie sorgt für Atomarität innerhalb einer Grenze. Konsistenz bei parallelen Zugriffen braucht zusätzlich passende Isolation, Locking, Versionen und fachliche Konfliktbehandlung.

Typische Symptome in echten Enterprise-Systemen

  • Kunden erhalten eine Bestellbestätigung, obwohl der Bestand nicht mehr reicht.
  • Rechnungen werden doppelt erzeugt, weil zwei Worker denselben Auftrag parallel finden.
  • Support speichert eine Änderung und überschreibt unbemerkt die Änderung eines anderen Benutzers.
  • Ein Batchjob blockiert tagsüber Online-Transaktionen.
  • Deadlocks treten unregelmäßig auf und verschwinden beim Debuggen.
  • Retries beheben scheinbar Fehler, erzeugen aber doppelte Nebenwirkungen.

Inventory Reservation zeigt, warum Locking nicht abstrakt bleiben darf.

2. Lost Update: der klassische Fehler

Lost Update ist einer der wichtigsten Konkurrenzfehler. Zwei Transaktionen lesen denselben alten Zustand, berechnen beide einen neuen Zustand und schreiben nacheinander zurück. Die spätere Transaktion überschreibt die frühere, ohne zu merken, dass ihre Grundlage veraltet war.

Die visuelle Abfolge zeigt, warum der finale Datenbankwert plausibel aussehen kann, obwohl die fachliche Wahrheit falsch ist.

Schlechte Lösung: Read - Calculate - Write ohne Version

Die folgende Klasse ist absichtlich schlecht. Sie wird im Lab benutzt, um den Fehler sichtbar zu machen. Der Fehler liegt nicht darin, dass die Klasse kurz ist, sondern darin, dass der Snapshot beim Commit nicht mehr validiert wird.

NaiveReservationSession.java - bewusstes Gegenbeispiel

JAVA
package com.example.lockdeepdive.inventory;
// Pattern: Gegenbeispiel - diese Klasse zeigt bewusst den Lost-Update-Fehler.
public final class NaiveReservationSession {
    private final InventoryTable table;
    private final StockSnapshot snapshot;
    private Integer plannedQuantity;
    public NaiveReservationSession(InventoryTable table, StockSnapshot snapshot) {
        this.table = table;
        this.snapshot = snapshot;
    }
public boolean planReservation(int requested) {
    if (requested <= 0) throw new IllegalArgumentException("requested must be positive");
    if (snapshot.quantity() < requested) return false;
    plannedQuantity = snapshot.quantity() - requested;
    return true;
}
public void commitWithoutVersionCheck() {
    if (plannedQuantity == null) throw new IllegalStateException("nothing planned");
    table.writeWithoutVersionCheck(snapshot.productId(), plannedQuantity);
}
public StockSnapshot snapshot() {
    return snapshot;
}
}

Warum das im Enterprise-Projekt gefährlich ist

  • Monitoring sieht möglicherweise keinen technischen Fehler, weil beide Writes erfolgreich waren.
  • Die Datenbank ist formal konsistent: quantity ist eine Zahl, Constraints können erfüllt sein.
  • Fachlich ist das System trotzdem falsch, weil mehr Bestand zugesagt wurde als vorhanden war.
  • Der Fehler wird schlimmer, wenn nachgelagerte Systeme wie Billing oder Reporting auf den scheinbar erfolgreichen Prozess reagieren.
Situation Technisch sichtbar Fachlicher Schaden
beide Transaktionen committen kein Fehler Bestand überverkauft
letzter Writer gewinnt finale Zahl wirkt plausibel erste Reservierung ist unsichtbar verloren
nachgelagerte Events gesendet Broker meldet OK Reporting und Kundennachricht widersprechen Lager

3. Optimistic Locking: Konflikte erkennen statt lange sperren

Optimistic Locking basiert auf der Annahme: Konflikte sind möglich, aber nicht der Normalfall. Deshalb hält das System beim Lesen keine lange Sperre. Stattdessen wird beim Schreiben geprüft, ob sich die Version seit dem Lesen verändert hat.

Versionierte Updates machen veraltete Entscheidungen sichtbar.

SQL-Mentalmodell

Optimistic Locking als SQL-Mentalmodell

SQL
UPDATE inventory
 SET quantity = ?, version = version + 1
 WHERE product_id = ?
 AND version = ?;
0 rows updated -> jemand war schneller; Konflikt behandeln
1 row updated -> Commit kann fortgesetzt werden

Bessere Lösung: Use Case lädt nach Konflikt neu

Der wichtige Punkt ist nicht nur der Version Check. Der Use Case muss nach einem Konflikt den neuen Zustand lesen und die fachliche Regel erneut prüfen. Ein technischer Retry ohne fachliche Neubewertung ist gefährlich.

OptimisticInventoryService.java

JAVA
package com.example.lockdeepdive.inventory;
import com.example.lockdeepdive.shared.*;
// Pattern: Application Service - kapselt Use-Case-Regeln und technische Konfliktbehandlung.
public final class OptimisticInventoryService {
    private final InventoryTable table;
    private final int maxRetries;
    public OptimisticInventoryService(InventoryTable table, int maxRetries) {
        this.table = table;
        this.maxRetries = maxRetries;
    }
public ReservationResult reserve(ProductId productId, int requested) {
    for (int attempt = 1; attempt <= maxRetries; attempt++) {
        StockSnapshot current = table.read(productId);
        if (current.quantity() < requested) {
            return new ReservationResult.Rejected(productId, requested, current.quantity());
        }
    int newQuantity = current.quantity() - requested;
    try {
        StockSnapshot saved = table.writeIfVersionMatches(productId, current.version(), newQuantity);
        return new ReservationResult.Reserved(productId, requested, saved.quantity(), saved.version());
    }
catch (OptimisticLockException conflict) {
    if (attempt == maxRetries) {
        return new ReservationResult.Conflict(productId, conflict.getMessage());
    }
// In echter Produktion: kleiner Backoff + Metrik + erneute fachliche Pruefung.
}
}
return new ReservationResult.Conflict(productId, "retry loop exhausted");
}
}

Wann Optimistic Locking gut passt

  • Benutzer bearbeiten Daten gelegentlich parallel, aber Konflikte sind nicht extrem häufig.
  • Konflikte sollen sichtbar als fachliche Meldung behandelt werden.
  • Lange Sperren wären teurer als gelegentliche Wiederholung.
  • Das Aggregate ist klein genug, um nach Konflikt neu geladen und bewertet zu werden.

Wann Vorsicht nötig ist

  • Sehr hohe Konkurrenz auf genau derselbe Zeile kann zu vielen Konflikten führen.
  • Blindes Retry kann Lastspitzen verstärken.
  • Eine fachliche Entscheidung darf nach Reload nicht automatisch dieselbe bleiben.
  • Bei langen Benutzer-Dialogen muss die UI erklären können, dass der Zustand inzwischen geändert wurde.

4. Isolation Levels: was die Transaktion sehen darf

Isolation beschreibt, welche Zwischeneffekte paralleler Transaktionen sichtbar sind. Sie ist nicht nur Performance-Einstellung, sondern beeinflusst fachliche Aussagen wie „Ich habe gerade geprüft, dass genug Bestand vorhanden ist“ oder „Diese Rechnung wurde noch nicht erzeugt“.

Die Matrix fasst typische Phänomene zusammen.

Read Committed

Read Committed verhindert, dass uncommittete Änderungen anderer Transaktionen gelesen werden. Aber dieselbe Query kann innerhalb einer Transaktion später ein anderes Ergebnis liefern, wenn inzwischen jemand anderes committed hat.

Repeatable Read

Repeatable Read stabilisiert bereits gelesene Zeilen innerhalb derselben Transaktion. Das hilft bei wiederholten Prüfungen, löst aber nicht automatisch alle Bereichsprobleme, insbesondere abhängig von Datenbank und konkreter Abfrageform.

Serializable

Serializable ist das strengste Modell. Es versucht, parallele Ausführungen so wirken zu lassen, als wären sie nacheinander passiert. Dafür können mehr Sperren, mehr Konflikte oder Abbrüche entstehen. In Enterprise-Systemen nutzt man es gezielt, nicht reflexartig überall.

SimulatedTransaction.java - Read Committed vs Repeatable Read im Lab

JAVA
package com.example.lockdeepdive.isolation;
import java.util.*;
// Pattern: Unit of Work Mental Model - Transaktion merkt sich je nach Isolation gelesene Snapshots.
public final class SimulatedTransaction {
    private final AccountTable table;
    private final IsolationLevel isolationLevel;
    private final Map<AccountId, AccountSnapshot> repeatableReadCache = new HashMap<>();
    public SimulatedTransaction(AccountTable table, IsolationLevel isolationLevel) {
        this.table = table;
        this.isolationLevel = isolationLevel;
    }
public AccountSnapshot read(AccountId id) {
    if (isolationLevel == IsolationLevel.REPEATABLE_READ) {
        return repeatableReadCache.computeIfAbsent(id, table::committedRead);
    }
return table.committedRead(id);
}
public IsolationLevel isolationLevel() {
    return isolationLevel;
}
}
Phänomen Bedeutung Beispiel
Dirty Read Lesen uncommitteter Daten Order sieht Payment-Status, der später rollbackt
Non-repeatable Read dieselbe Zeile liefert später anderen Wert Support liest Order zweimal und sieht verschiedene Stati
Phantom Read Bereichsabfrage liefert neue/fehlende Zeilen Batch findet beim zweiten Scan zusätzliche Rechnungen
Lost Update Write überschreibt anderen Write Inventory-Reservierung überschreibt parallele Reservierung

5. Pessimistic Locking: bewusst sperren, aber kurz

Pessimistic Locking basiert auf der Annahme: Konflikte sind wahrscheinlich oder teuer. Deshalb wird der kritische Datensatz gesperrt, bevor die fachliche Entscheidung getroffen und gespeichert wird. Das kann korrekt und notwendig sein - aber nur mit sehr kurzen Transaktionen.

Pessimistische Sperren müssen so kurz wie möglich gehalten werden.

SELECT FOR UPDATE als Mentalmodell

Pessimistic Locking in SQL-Denkweise

SQL
BEGIN;
SELECT quantity, version
 FROM inventory
 WHERE product_id = 'SKU-1'
 FOR UPDATE;
-- jetzt: pruefen, reduzieren, Outbox schreiben
COMMIT;

Lock Manager im JDK-only-Lab

RowLockManager.java

JAVA
package com.example.lockdeepdive.locking;
import com.example.lockdeepdive.shared.ProductId;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
// Pattern: Lock Manager - simuliert DB-Zeilensperren mit Timeout.
public final class RowLockManager {
    private final ConcurrentMap<ProductId, ReentrantLock> locks = new ConcurrentHashMap<>();
    public Guard lock(ProductId productId, Duration timeout) {
        ReentrantLock lock = locks.computeIfAbsent(productId, id -> new ReentrantLock());
        try {
            if (!lock.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
                throw new LockTimeoutException("timeout while waiting for lock on " + productId);
            }
        return new Guard(productId, lock);
    }
catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new LockTimeoutException("interrupted while waiting for lock on " + productId);
}
}
public record Guard(ProductId productId, ReentrantLock lock) implements AutoCloseable {
    @Override public void close() {
        lock.unlock();
    }
}
}

Use Case mit kurzer kritischer Sektion

PessimisticInventoryService.java

JAVA
package com.example.lockdeepdive.locking;
import com.example.lockdeepdive.inventory.*;
import com.example.lockdeepdive.shared.*;
import java.time.Duration;
// Pattern: Application Service + Pessimistic Lock - kurze kritische Sektion, keine externen API-Aufrufe im Lock.
public final class PessimisticInventoryService {
    private final InventoryTable table;
    private final RowLockManager locks;
    private final Duration timeout;
    public PessimisticInventoryService(InventoryTable table, RowLockManager locks, Duration timeout) {
        this.table = table;
        this.locks = locks;
        this.timeout = timeout;
    }
public ReservationResult reserve(ProductId productId, int requested) {
    try (RowLockManager.Guard ignored = locks.lock(productId, timeout)) {
        StockSnapshot current = table.read(productId);
        if (current.quantity() < requested) {
            return new ReservationResult.Rejected(productId, requested, current.quantity());
        }
    StockSnapshot saved = table.writeIfVersionMatches(productId, current.version(), current.quantity() - requested);
    return new ReservationResult.Reserved(productId, requested, saved.quantity(), saved.version());
}
catch (LockTimeoutException timeout) {
    return new ReservationResult.Conflict(productId, timeout.getMessage());
}
}
}

Was niemals in die Sperre gehört

  • HTTP-Aufruf zu Payment Provider
  • E-Mail-Versand
  • Kafka/JMS Publish
  • lange PDF-Erzeugung
  • Benutzerinteraktion
  • Batch-Loop über viele Datensätze ohne Chunking

Produktionsregel
Eine Sperre schützt den kritischen Zustand. Sie darf nicht benutzt werden, um beliebig lange Nebenwirkungen bequem in einer Transaktion zu halten.

6. Deadlocks verstehen und vermeiden

Ein Deadlock entsteht, wenn Transaktionen zyklisch auf Ressourcen warten. Klassisch: Transaktion A hält Lock 1 und wartet auf Lock 2, Transaktion B hält Lock 2 und wartet auf Lock 1. Ohne Timeout oder Deadlock Detection kann keine der beiden fortfahren.

Der Wait-for Graph macht den Kreis sichtbar.

Echte Ursachen

  • unterschiedliche Lock-Reihenfolge in verschiedenen Codepfaden
  • lange Transaktionen mit zu vielen Ressourcen
  • Batchjob und Online-Use-Case greifen in anderer Reihenfolge zu
  • Index fehlt, sodass die Datenbank mehr Zeilen sperrt als erwartet
  • Retry ohne Backoff erzeugt sofort denselben Konflikt erneut

Deterministische Deadlock-Simulation

DeadlockSimulator.java

JAVA
package com.example.lockdeepdive.deadlock;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
// Pattern: Deterministic Concurrency Test Helper - erzwingt gegensaetzliche Lock-Reihenfolge reproduzierbar.
public final class DeadlockSimulator {
    private final ReentrantLock productOne = new ReentrantLock();
    private final ReentrantLock productTwo = new ReentrantLock();
    public DeadlockSimulationResult simulateOppositeLockOrder(Duration timeout) {
        CountDownLatch bothHoldFirstLock = new CountDownLatch(2);
        ExecutorService executor = Executors.newFixedThreadPool(2);
        try {
            Future<Boolean> first = executor.submit(() -> worker(productOne, productTwo, bothHoldFirstLock, timeout));
            Future<Boolean> second = executor.submit(() -> worker(productTwo, productOne, bothHoldFirstLock, timeout));
            boolean firstTimedOut = first.get(5, TimeUnit.SECONDS);
            boolean secondTimedOut = second.get(5, TimeUnit.SECONDS);
            return new DeadlockSimulationResult(firstTimedOut, secondTimedOut,
            "opposite lock order created a wait cycle; timeout broke at least one worker");
        }
    catch (Exception e) {
        throw new IllegalStateException("deadlock simulation failed", e);
    }
finally {
    executor.shutdownNow();
}
}
private boolean worker(ReentrantLock first, ReentrantLock second, CountDownLatch latch, Duration timeout) throws Exception {
    first.lock();
    try {
        latch.countDown();
        if (!latch.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("test setup failed");
        boolean acquiredSecond = second.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS);
        if (!acquiredSecond) return true;
        try {
            return false;
        }
    finally {
        second.unlock();
    }
}
finally {
    first.unlock();
}
}
}

Gegenmaßnahmen

Maßnahme Warum sie hilft Grenze
feste Lock-Reihenfolge verhindert Kreise im Wait-for Graph muss in allen Codepfaden gelten
kurze Transaktionen reduziert Wartezeit und Konfliktfenster braucht klare Use-Case-Schnitte
Lock Timeout System bleibt reaktionsfähig Fehler muss fachlich/technisch behandelt werden
Retry mit Backoff transiente Konflikte können verschwinden darf keine doppelten Nebenwirkungen erzeugen
gute Indizes Datenbank sperrt weniger unnötige Zeilen Query-Pläne müssen beobachtet werden

7. Retry nach Konflikt: nicht blind wiederholen

Retry ist kein magischer Korrektheitsmechanismus. Ein Retry ist nur dann sicher, wenn der Use Case idempotent ist oder Nebenwirkungen sauber isoliert sind. Bei Optimistic Locking muss der neue Zustand geladen und die fachliche Regel neu bewertet werden.

Die Entscheidung nach einem Konflikt ist fachlich, nicht nur technisch.

Guter Retry

  • kurz begrenzt
  • mit Backoff
  • mit neuem Read
  • mit erneuter fachlicher Prüfung
  • mit Metrik und Logging
  • ohne irreversible Nebenwirkung innerhalb des wiederholten Blocks

Schlechter Retry

  • wiederholt Payment-Aufruf
  • sendet E-Mail erneut
  • publiziert Event erneut ohne Idempotenz
  • verschluckt Konflikte als Erfolg
  • läuft unbegrenzt und erhöht Lastspitzen

Faustregel
Retry darf eine technische Kollision überbrücken. Retry darf keine fachliche Entscheidung erzwingen, die nach dem neuen Zustand nicht mehr gültig ist.

8. JPA/Hibernate-Bezug: @Version, LockMode und Fetch-Pläne

Dieses Kapitel-Lab bleibt JDK-only, aber die Mechanik entspricht typischen JPA/Hibernate-Fragen. @Version ist das Standardwerkzeug für Optimistic Locking. Pessimistic Locking wird über Lock Modes oder Query-Hints angestoßen. Isolation kommt weiterhin aus Datenbank und Transaktionskonfiguration.

@Version Mentalmodell

JPA @Version als Optimistic-Locking-Werkzeug

JAVA
@Entity
class InventoryItemEntity {
    @Id String productId;
    int quantity;
    @Version
    long version;
}
-- Hibernate-Update enthaelt typischerweise eine Versionsbedingung.

Typische Fehler mit JPA

  • @Version fehlt auf Daten, die parallel geändert werden.
  • OptimisticLockException wird technisch geloggt, aber nicht fachlich übersetzt.
  • Pessimistic Lock wird gesetzt, aber danach wird eine externe API in derselben Transaktion aufgerufen.
  • Fetch Join behebt N+1, erzeugt aber plötzlich sehr breite Ergebnismengen.
  • Open Session in View versteckt Lazy-Loading-Probleme bis in die UI-Schicht.

LockMode richtig denken

Lock Mode Gedanke Einsatz
OPTIMISTIC Konflikt beim Commit/Flush erkennen Benutzer- und Aggregate-Updates
OPTIMISTIC_FORCE_INCREMENT Version bewusst erhöhen fachliche Reservierung/Anspruch sichtbar machen
PESSIMISTIC_WRITE Schreibkonflikte durch Sperre verhindern hochkonkurrierte Ressourcen
PESSIMISTIC_READ Änderung durch andere erschweren spezielle Lesekonsistenzfälle

9. Tests: Konkurrenzfehler reproduzierbar machen

Concurrency-Fehler sind schwer, wenn Tests zufällig sind. Das Lab nutzt deshalb deterministische Simulationen: veraltete Snapshots werden kontrolliert erzeugt, Isolation wird über eine kleine Transaktionsklasse modelliert, Deadlock wird über Latches und Timeouts reproduzierbar gemacht.

Kapitel2TestRunner.java - Szenario-Tests

JAVA
package com.example.lockdeepdive;
import com.example.lockdeepdive.deadlock.*;
import com.example.lockdeepdive.inventory.*;
import com.example.lockdeepdive.isolation.*;
import com.example.lockdeepdive.locking.*;
import com.example.lockdeepdive.shared.*;
import java.time.Duration;
public final class Kapitel2TestRunner {
    public static void main(String[] args) {
        lostUpdateIsVisibleWithoutVersionCheck();
        optimisticLockDetectsStaleWrite();
        optimisticServiceRechecksBusinessRuleAfterConflict();
        pessimisticLockSerializesCriticalSection();
        readCommittedAllowsNonRepeatableRead();
        repeatableReadKeepsStableSnapshot();
        deadlockIsBrokenByTimeout();
        System.out.println("Kapitel2_TESTS_OK");
    }
static void lostUpdateIsVisibleWithoutVersionCheck() {
    ProductId sku = ProductId.of("SKU-1");
    InventoryTable table = new InventoryTable();
    table.insert(sku, 10);
    NaiveReservationSession txA = new NaiveReservationSession(table, table.read(sku));
    NaiveReservationSession txB = new NaiveReservationSession(table, table.read(sku));
    assertTrue(txA.planReservation(7), "txA can reserve 7");
    assertTrue(txB.planReservation(6), "txB can reserve 6 based on stale snapshot");
    txA.commitWithoutVersionCheck();
    txB.commitWithoutVersionCheck();
    assertEquals(4, table.quantity(sku), "last writer wins although 13 items were promised from stock 10");
}
static void optimisticLockDetectsStaleWrite() {
    ProductId sku = ProductId.of("SKU-2");
    InventoryTable table = new InventoryTable();
    table.insert(sku, 10);
    StockSnapshot a = table.read(sku);
    StockSnapshot b = table.read(sku);
    table.writeIfVersionMatches(sku, a.version(), 3);
    assertThrows(OptimisticLockException.class, () -> table.writeIfVersionMatches(sku, b.version(), 4),
    "stale writer must be rejected");
}
static void optimisticServiceRechecksBusinessRuleAfterConflict() {
    ProductId sku = ProductId.of("SKU-3");
    InventoryTable table = new InventoryTable();
    table.insert(sku, 10);
    OptimisticInventoryService service = new OptimisticInventoryService(table, 3);
    ReservationResult first = service.reserve(sku, 7);
    ReservationResult second = service.reserve(sku, 6);
    assertTrue(first.success(), "first reservation succeeds");
    assertTrue(!second.success(), "second reservation is rejected after reread because only 3 remain");
}
static void pessimisticLockSerializesCriticalSection() {
    ProductId sku = ProductId.of("SKU-4");
    InventoryTable table = new InventoryTable();
    table.insert(sku, 10);
    PessimisticInventoryService service = new PessimisticInventoryService(table, new RowLockManager(), Duration.ofMillis(500));
    ReservationResult first = service.reserve(sku, 6);
    ReservationResult second = service.reserve(sku, 5);
    assertTrue(first.success(), "first reservation succeeds");
    assertTrue(!second.success(), "second reservation sees updated remaining stock and fails fachlich");
    assertEquals(4, table.quantity(sku), "stock remains consistent");
}
static void readCommittedAllowsNonRepeatableRead() {
    AccountId account = AccountId.of("A-1");
    AccountTable table = new AccountTable();
    table.insert(account, 100);
    SimulatedTransaction tx = new SimulatedTransaction(table, IsolationLevel.READ_COMMITTED);
    int first = tx.read(account).balance();
    table.update(account, 150);
    int second = tx.read(account).balance();
    assertEquals(100, first, "first read");
    assertEquals(150, second, "second read sees newer committed value");
}
static void repeatableReadKeepsStableSnapshot() {
    AccountId account = AccountId.of("A-2");
    AccountTable table = new AccountTable();
    table.insert(account, 100);
    SimulatedTransaction tx = new SimulatedTransaction(table, IsolationLevel.REPEATABLE_READ);
    int first = tx.read(account).balance();
    table.update(account, 150);
    int second = tx.read(account).balance();
    assertEquals(100, first, "first read");
    assertEquals(100, second, "repeatable read keeps snapshot stable inside transaction");
}
static void deadlockIsBrokenByTimeout() {
    DeadlockSimulationResult result = new DeadlockSimulator().simulateOppositeLockOrder(Duration.ofMillis(200));
    assertTrue(result.deadlockWasBrokenByTimeout(), "timeout should break wait cycle");
}
static void assertTrue(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
static void assertEquals(int expected, int actual, String message) {
    if (expected != actual) throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual);
}
static void assertThrows(Class<? extends Throwable> type, Runnable action, String message) {
    try {
        action.Kapitel();
    }
catch (Throwable t) {
    if (type.isInstance(t)) return;
    throw new AssertionError(message + ": wrong exception " + t);
}
throw new AssertionError(message + ": expected exception " + type.getSimpleName());
}
}

Was gute Tests hier prüfen

  • Lost Update ist ohne Version Check sichtbar.
  • Stale Writer wird mit Optimistic Locking abgelehnt.
  • Nach Konflikt wird fachliche Regel neu geprüft.
  • Pessimistic Locking serialisiert die kritische Sektion.
  • Read Committed und Repeatable Read unterscheiden sich im selben Szenario.
  • Deadlock wird nicht ignoriert, sondern durch Timeout sichtbar.

Teststrategie
Concurrency-Tests sollen nicht beweisen, dass Threads immer gleich laufen. Sie sollen kritische Reihenfolgen gezielt erzwingen und dadurch das fachliche Risiko erklären.

10. Produktionshinweise: Metriken, Timeouts, Indizes

Locking-Probleme werden oft erst im Betrieb sichtbar. Deshalb gehören Metriken, Logging und Datenbankbeobachtung zur Lösung. Wer nur Code schreibt, aber keine Konflikte misst, sieht die wichtigsten Signale zu spät.

Metriken

  • Anzahl OptimisticLock-Konflikte pro Use Case
  • Lock Wait Time
  • Deadlock Count
  • Retry Count und Retry Exhausted Count
  • Dauer kritischer Transaktionsabschnitte
  • Outbox-Lag und Consumer-Duplikate
  • Datenbank-Connection-Pool-Auslastung

Logging

  • Correlation ID
  • Use Case Name
  • Aggregate ID
  • Version alt/neu
  • Retry Attempt
  • Lock Timeout
  • fachlicher Ablehnungsgrund

Index- und Query-Praxis

Fehlende Indizes können Locking drastisch verschlechtern. Wenn eine Datenbank für eine Änderung viele Zeilen scannen muss, sperrt sie oft mehr und länger als fachlich erwartet. Deshalb gehört Query-Plan-Prüfung zu Locking-Design.

Check Frage
Transaktionsdauer Wie lange bleibt die Transaktion offen?
Lock-Reihenfolge Greifen alle Codepfade in derselben Reihenfolge zu?
Timeouts Gibt es klare Obergrenzen?
Retry Ist Retry idempotent und begrenzt?
Indizes Findet die DB gezielt die betroffenen Zeilen?
User Experience Kann die UI Konflikte verständlich erklären?

11. Code-Lab Wegweiser

Das Code-Lab ist bewusst ohne externe Datenbank gebaut. Dadurch kann man die Mechanik lesen, kompilieren und ausführen. Es ersetzt keine echte Datenbanktests, aber es macht die Ursache der Fehler sichtbar.

Projektstruktur

SQL
code/locking-isolation-deep-dive-lab/
├── src/main/java/com/example/lockdeepdive/
 ├── inventory/ Lost Update, Optimistic Locking
 ├── isolation/ Read Committed vs Repeatable Read
 ├── locking/ RowLockManager, Pessimistic Locking
 ├── deadlock/ deterministische Deadlock-Simulation
 └── demo/ Demo Runner
├── src/test/java/ Kapitel2TestRunner
└── docs/ Design Patterns und ADRs

Kompilieren und ausführen

Befehle im Lab-Ordner

TEXT
javac --release 21 -d target/classes $(find src/main/java src/test/java -name "*.java")
java -cp target/classes com.example.lockdeepdive.demo.Kapitel2Demo
java -cp target/classes com.example.lockdeepdive.Kapitel2TestRunner

12. Checkliste für echte Projekte

  • Jedes parallel änderbare Aggregate hat eine Konfliktstrategie.
  • Versionierte Updates sind für Benutzeränderungen Standard.
  • Hochkonkurrierte Ressourcen haben kurze, explizite Sperren oder eine fachliche Reservierungsstrategie.
  • Externe Nebenwirkungen laufen nicht in langen Locks.
  • Deadlock-Timeouts und Lock-Wait-Metriken sind sichtbar.
  • Retries sind begrenzt, idempotent und mit Backoff versehen.
  • Die UI kann Konflikte fachlich erklären.
  • Batchjobs arbeiten in Chunks und halten nicht unnötig lange Transaktionen.
  • Query-Pläne und Indizes werden bei kritischen Updates geprüft.
  • Outbox und Idempotenz werden mit Locking-Entscheidungen zusammen gedacht.

Weiter
Kapitel vertieft Outbox, Idempotenz, Retry und Crash-Fälle. Dort wird aus Locking-Korrektheit eine robuste Integrationsstrategie.

Code-Labs zu diesem Kapitel

Outbox & ZuverlässigkeitIdempotenz, Crash-Szenarien und stabiler Produktionsbetrieb.
04. Outbox, Idempotenz und Crash-Faelle

Kapitelkompass

Crash-Fenster explizit schließen

Die Outbox verbindet lokale Persistenz und spätere Veröffentlichung, ohne eine verteilte Transaktion vorzutäuschen.

DB CommitAusgangspunktOutboxEinordnenPublishAbsichernDedupErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Crash-Fenster benennen
  • Idempotency Keys nutzen
  • Duplikate tolerieren
  • Recovery testen

Praxisfall

Der Prozess stirbt nach Datenbank-Commit, aber vor der Bestätigung des Brokers.

Entscheidung

At-least-once plus idempotente Verarbeitung ist meist robuster als vermeintliches Exactly-once.

!

Typisches Risiko

Eine Outbox ohne deduplizierenden Consumer verschiebt das Duplikatproblem nur weiter.

Thema: Outbox, Idempotenz, Retry und Crash-Fälle Deep Dive

Dieser Kapitel vertieft Outbox, Idempotenz, Retry und Crash-Fälle als eigenes Lernkapitel mit echtem Java-21-Code-Lab.

1. Orientierung: Warum Outbox ein Deep-Dive-Thema ist

Transactional Outbox ist kein dekoratives Integrationsmuster, sondern eine Antwort auf ein sehr konkretes Produktionsproblem: Ein Use Case schreibt fachlichen Zustand in eine Datenbank und muss danach ein Ereignis an andere Systeme liefern. Wenn diese beiden Aktionen nicht gemeinsam konsistent behandelt werden, entstehen Lücken zwischen Order, Billing, Reporting und Audit.

Das Thema ist tiefer als “wir speichern Events in einer Tabelle”. Entscheidend sind die Zwischenfälle: Der Prozess stürzt nach dem Commit ab. Der Broker nimmt ein Event an, aber die Anwendung kann den Status nicht mehr aktualisieren. Der Consumer erhält dieselbe Nachricht zweimal. Retry erzeugt eine Lastwelle. Eine vermeintlich erledigte Nachricht landet nach Stunden wieder im System. Genau diese Fälle werden in diesem Kapitel als Szenario, Code und Test behandelt.

Deep-Dive-Regel: Ein Event-Flow ist erst dann robust verstanden, wenn nicht nur der Happy Path funktioniert, sondern jeder Crash-Punkt einen erwarteten, testbaren Folgezustand hat.

2. Das Dual-Write-Problem: Warum direkte Broker-Aufrufe gefährlich sind

Die naive Lösung sieht in Reviews oft harmlos aus: Order speichern, danach broker.publish(orderAcceptedEvent) aufrufen. Das Problem liegt nicht im Codeformat, sondern in der fehlenden gemeinsamen Transaktionsgrenze. Die Datenbank und der Broker können nicht als eine atomare Operation behandelt werden, solange keine verteilte Transaktion eingesetzt wird. Verteilte Transaktionen sind in modernen Cloud-/Container-Landschaften oft unerwünscht, teuer oder gar nicht sauber verfügbar.

Eine schlechte Lösung mischt fachliche Konsistenz und Infrastrukturverfügbarkeit in derselben Methode. Wenn der Broker kurz nicht erreichbar ist, weiß der Use Case plötzlich nicht mehr, ob er die Bestellung fachlich akzeptieren oder ablehnen soll. Wenn das Event schon gesendet wurde und danach die DB-Transaktion fehlschlägt, sehen abhängige Systeme eine Bestellung, die es in der Datenbank nicht gibt. Wenn die DB committet, aber der Prozess vor dem Senden stirbt, fehlt das Event vollständig.

JAVA
// Schlechte Skizze: zwei Wahrheiten ohne gemeinsame Transaktionsgrenze.
@Transactional
public void placeOrder(PlaceOrderCommand command) {
    Order order = Order.accept(command.orderId(), command.total(), clock.instant());
    orderRepository.save(order);
    // Gefährlich: Broker-Aufruf ist nicht Teil derselben DB-Transaktion.
    broker.publish(new OrderAcceptedEvent(order.id(), order.total()));
}

Diese Methode ist nicht deshalb schlecht, weil sie kurz ist. Sie ist schlecht, weil sie keinen verlässlichen Wiederanlaufpunkt besitzt. Nach einem Crash kann das System nicht aus der Datenbank rekonstruieren, ob noch etwas zu senden ist.

3. Transactional Outbox: Fachlicher Zustand und Publikationsabsicht in einer Transaktion

Die Outbox-Lösung verschiebt den Broker-Aufruf aus der Use-Case-Transaktion. Der Application Service schreibt den fachlichen Zustand und zusätzlich eine Outbox-Zeile. Diese Zeile ist keine technische Nebensache, sondern eine persistente fachliche Absicht: “Dieses Ereignis muss veröffentlicht werden”.

Der wichtige Schnitt ist: Der Use Case endet nach dem DB-Commit. Der Publisher ist ein separater Prozess. Dadurch wird der Broker-Ausfall nicht zur unmittelbaren Frage, ob eine Bestellung fachlich angenommen wurde. Die Bestellung ist angenommen, wenn die DB-Transaktion mit Order und Outbox erfolgreich committed wurde. Der Versand wird nachgeholt.

JAVA
package com.example.outboxdeepdive.order;
import com.example.outboxdeepdive.outbox.*;
import com.example.outboxdeepdive.shared.*;
import java.time.Instant;
// Pattern: Application Service - definiert Use-Case- und Transaktionsgrenze.
public final class PlaceOrderService {
    private final OrderRepository orders;
    private final OutboxRepository outbox;
    public PlaceOrderService(OrderRepository orders, OutboxRepository outbox) {
        this.orders = orders;
        this.outbox = outbox;
    }
public OrderId placeOrder(String id, Money total, Instant now) {
    Order order = Order.accept(OrderId.of(id), total, now);
    // In einer echten Datenbank waere dies eine Transaktion:
    // 1) Order speichern
    // 2) OutboxMessage speichern
    // 3) Commit
    orders.save(order);
    outbox.save(OutboxMessage.pending("Order", order.id().value(), "OrderAccepted", 1, payload(order), now));
    return order.id();
}
private String payload(Order order) {
    return "{"orderId":"" + order.id().value() + "","amount":"" + order.total().amount() + "","currency":"" + order.total().currency() + ""}";
}
}

Der Kommentar im Code ist Absicht: In diesem JDK-only-Lab gibt es keine echte Datenbanktransaktion. Die Struktur bildet aber die echte Zielarchitektur ab. In Spring, Jakarta EE oder Quarkus läge hier eine echte transaktionale Grenze um orders.save(order) und outbox.save(message).

4. OutboxMessage als Zustandsmaschine statt simpler Tabelle

Eine Outbox-Tabelle mit nur id, payload und sent ist für Lernbeispiele ausreichend, aber für produktionsnahe Systeme zu schwach. Man braucht Zustand, Versuchszahl, Fehlermeldung, nächste Ausführungszeit und oft auch Event-Version, Aggregate-ID und Event-Typ. Sonst wird Betrieb schwierig: Man kann nicht gut erkennen, ob Nachrichten hängen, ob Retry eskaliert oder ob ein bestimmtes Aggregat wiederholt Probleme macht.

JAVA
package com.example.outboxdeepdive.outbox;
import java.time.Instant;
import java.util.*;
// Pattern: Outbox Message - persistente Publikationsabsicht mit explizitem Zustand.
public final class OutboxMessage {
    private final UUID id;
    private final String aggregateType;
    private final String aggregateId;
    private final String eventType;
    private final int eventVersion;
    private final String payload;
    private final Instant createdAt;
    private OutboxStatus status;
    private int attempts;
    private Instant nextAttemptAt;
    private String lastError;
    private OutboxMessage(UUID id, String aggregateType, String aggregateId, String eventType, int eventVersion, String payload, Instant createdAt) {
        this.id = Objects.requireNonNull(id);
        this.aggregateType = require(aggregateType, "aggregateType");
        this.aggregateId = require(aggregateId, "aggregateId");
        this.eventType = require(eventType, "eventType");
        this.eventVersion = eventVersion;
        this.payload = require(payload, "payload");
        this.createdAt = Objects.requireNonNull(createdAt);
        this.status = OutboxStatus.PENDING;
        this.nextAttemptAt = createdAt;
    }
public static OutboxMessage pending(String aggregateType, String aggregateId, String eventType, int version, String payload, Instant now) {
    return new OutboxMessage(UUID.randomUUID(), aggregateType, aggregateId, eventType, version, payload, now);
}
private static String require(String s, String n) {
    if (s == null || s.isBlank()) throw new IllegalArgumentException(n + " required");
    return s;
}
public UUID id() {
    return id;
}
public String aggregateType() {
    return aggregateType;
}
public String aggregateId() {
    return aggregateId;
}
public String eventType() {
    return eventType;
}
public int eventVersion() {
    return eventVersion;
}
public String payload() {
    return payload;
}
public Instant createdAt() {
    return createdAt;
}
public OutboxStatus status() {
    return status;
}
public int attempts() {
    return attempts;
}
public Instant nextAttemptAt() {
    return nextAttemptAt;
}
public String lastError() {
    return lastError;
}
public boolean dueAt(Instant now) {
    return (status == OutboxStatus.PENDING || status == OutboxStatus.FAILED) && !nextAttemptAt.isAfter(now);
}
public void claim() {
    if (status != OutboxStatus.PENDING && status != OutboxStatus.FAILED) throw new IllegalStateException("cannot claim " + status);
    status = OutboxStatus.IN_FLIGHT;
}
public void markSent() {
    status = OutboxStatus.SENT;
    lastError = null;
}
public void markFailed(String error, Instant nextAttemptAt, int maxAttempts) {
    attempts++;
    lastError = error;
    this.nextAttemptAt = nextAttemptAt;
    status = attempts >= maxAttempts ? OutboxStatus.DEAD_LETTER : OutboxStatus.FAILED;
}
public void requeueAfterPublisherCrash() {
    if (status == OutboxStatus.IN_FLIGHT) status = OutboxStatus.PENDING;
}
}

Wichtig ist die Unterscheidung zwischen FAILED und DEAD_LETTER. FAILED bedeutet: automatisch erneut versuchen. DEAD_LETTER bedeutet: automatische Wiederholung wurde beendet, weil sonst ein Endlos-Fehler entsteht. Ein gutes System macht Dead-Letter-Fälle sichtbar und replaybar, aber nicht unsichtbar automatisch unendlich oft.

5. Publisher-Design: Claim, Send, Mark Sent, Retry

Der Publisher ist kein simpler Cron-Job, der select * from outbox where sent=false liest. Ein produktionsnaher Publisher muss Nachrichten kontrolliert beanspruchen, in kleinen Batches senden, Fehler speichern und mit Backoff erneut planen. Außerdem muss er nach einem Prozess-Crash mit halbfertigen IN_FLIGHT-Nachrichten umgehen.

JAVA
package com.example.outboxdeepdive.outbox;
import com.example.outboxdeepdive.broker.*;
import java.time.*;
// Pattern: Publisher - entkoppelt DB-Transaktion und Broker-Versand.
public final class OutboxPublisher {
    private final OutboxRepository repository;
    private final BrokerPort broker;
    private final int batchSize;
    private final int maxAttempts;
    public OutboxPublisher(OutboxRepository repository, BrokerPort broker, int batchSize, int maxAttempts) {
        this.repository = repository;
        this.broker = broker;
        this.batchSize = batchSize;
        this.maxAttempts = maxAttempts;
    }
public int publishDue(Instant now) {
    int sent = 0;
    for (OutboxMessage message : repository.findDue(now, batchSize)) {
        message.claim();
        try {
            broker.publish(new EventEnvelope(message.id(), message.eventType(), message.eventVersion(), message.payload()));
            message.markSent();
            sent++;
        }
    catch (RuntimeException ex) {
        message.markFailed(ex.getClass().getSimpleName() + ": " + ex.getMessage(), nextAttempt(now, message.attempts() + 1), maxAttempts);
    }
}
return sent;
}
private Instant nextAttempt(Instant now, int attempt) {
    long seconds = Math.min(300, (long)Math.pow(2, Math.max(1, attempt)));
    return now.plusSeconds(seconds);
}
public void recoverInFlightAfterCrash() {
    repository.all().forEach(OutboxMessage::requeueAfterPublisherCrash);
}
}

In einer echten Datenbank würde das Claiming meist mit kurzen Transaktionen, FOR UPDATE SKIP LOCKED oder atomaren Status-Updates umgesetzt. Dieser Kapitel erklärt das Prinzip ohne DB-Abhängigkeit. Entscheidend ist: Mehrere Publisher-Instanzen dürfen nicht dieselbe Nachricht gleichzeitig senden, und ein Crash darf eine Nachricht nicht dauerhaft unsichtbar machen.

6. Crash-Matrix: Der wichtigste Teil des Outbox-Verständnisses

Wer Outbox wirklich verstehen will, muss die Crash-Matrix lesen können. Der Happy Path beweist wenig. Robustheit zeigt sich an den Stellen, an denen der Prozess stirbt oder eine abhängige Komponente nur teilweise geantwortet hat.

Die kritischste Zeile ist “nach Send vor SENT-Markierung”. Der Broker kann das Event bereits an den Consumer geliefert haben, während die Outbox-Zeile noch nicht als SENT markiert wurde. Beim nächsten Publisher-Lauf kann dieselbe Nachricht erneut gesendet werden. Das ist kein Bug im Outbox-Muster, sondern eine direkte Folge mindestens-einmal-Zustellung. Deshalb braucht der Consumer Idempotenz.

Merksatz: Outbox löst das Dual-Write-Problem zwischen Datenbank und Broker, aber sie macht keine exakt-einmal-Fachwirkung. Diese Verantwortung liegt beim Consumer.

7. Idempotenter Consumer: Doppelte Zustellung ohne doppelte Wirkung

Ein Consumer ist idempotent, wenn dieselbe Nachricht mehrfach ankommen darf, ohne dass die fachliche Wirkung mehrfach ausgeführt wird. Für Billing bedeutet das: dieselbe OrderAccepted-Nachricht darf nicht zwei Rechnungen erzeugen. Für Inventory bedeutet es: dieselbe Reservierung darf nicht zweimal Bestand reduzieren.

JAVA
package com.example.outboxdeepdive.billing;
import com.example.outboxdeepdive.broker.EventEnvelope;
// Pattern: Idempotent Consumer - verarbeitet dasselbe Event mehrfach ohne doppelte Rechnung.
public final class BillingConsumer {
    private final IdempotencyStore idempotencyStore;
    private final InvoiceRepository invoices;
    public BillingConsumer(IdempotencyStore idempotencyStore, InvoiceRepository invoices) {
        this.idempotencyStore = idempotencyStore;
        this.invoices = invoices;
    }
public void onMessage(EventEnvelope envelope) {
    if (idempotencyStore.alreadyProcessed(envelope.messageId())) return;
    String orderId = extractOrderId(envelope.payload());
    invoices.createInvoice(orderId);
    idempotencyStore.markProcessed(envelope.messageId());
}
private String extractOrderId(String json) {
    String marker = ""orderId":"";
    int start = json.indexOf(marker);
    if (start < 0) throw new IllegalArgumentException("orderId missing");
    start += marker.length();
    int end = json.indexOf('"', start);
    return json.substring(start, end);
}
}

Der IdempotencyStore muss in echten Systemen dauerhaft sein, nicht nur ein In-Memory-Set. Häufig nutzt man eine Tabelle mit message_id als Unique Key. Wichtig ist die Reihenfolge: fachliche Wirkung und Markierung als verarbeitet müssen ebenfalls konsistent behandelt werden. Sonst entsteht ein neues Dual-Write-Problem auf Consumer-Seite.

8. Retry, Backoff und Dead Letter Queue

Retry ist ein Schutz gegen transiente Fehler. Aber Retry ohne Grenze ist eine Produktionsgefahr. Wenn ein Broker, Schema oder Consumer dauerhaft kaputt ist, erzeugt blindes Wiederholen nur Last, Logs und Folgefehler. Deshalb braucht der Publisher Versuchszähler, Backoff, Max-Attempts und klare Dead-Letter-Behandlung.

Im Lab sieht man das bewusst einfach: markFailed erhöht die Versuchszahl und setzt nextAttemptAt. Nach dem Maximalwert landet die Nachricht in DEAD_LETTER. In echten Systemen kommt noch Operations-Logik dazu: Alerting, manuelle Freigabe, Replay nach Fix, eventuell Quarantäne für fehlerhafte Event-Versionen.

9. Event-Versionierung und Schema-Evolution

Ein Event ist ein Vertrag zwischen Teams und Systemen. Eine Java-Klasse kann schnell geändert werden, aber veröffentlichte Events leben oft länger als der Code, der sie ursprünglich erzeugt hat. Deshalb ist Event-Versionierung kein späteres Detail, sondern Teil des Designs.

Kompatible Änderungen sind Erweiterungen, die alte Consumer ignorieren können. Gefährlich sind Bedeutungsänderungen: Wenn amount früher Brutto und später Netto meint, ist die Signatur vielleicht gleich, aber die Fachlichkeit kaputt. Ein Deep-Dive-Buch muss solche Fehler sichtbar machen, weil sie in der Praxis teurer sind als Syntaxfehler.

10. Tests: Crash-Fälle als Pflicht, nicht als Bonus

Die Tests in diesem Kapitel prüfen nicht nur, ob ein Event gesendet wird. Sie prüfen die entscheidenden Fehlerfälle: Order und Outbox werden gemeinsam geschrieben, Broker-Fehler lassen die Message retryfähig, doppelte Consumer-Zustellung erzeugt nur eine Rechnung, Max-Attempts führen zu Dead Letter.

JAVA
package com.example.outboxdeepdive;
import com.example.outboxdeepdive.billing.*;
import com.example.outboxdeepdive.broker.*;
import com.example.outboxdeepdive.order.*;
import com.example.outboxdeepdive.outbox.*;
import com.example.outboxdeepdive.shared.*;
import java.time.Instant;
public final class Kapitel3Tests {
    public static void main(String[] args) {
        orderAndOutboxAreWrittenTogether();
        brokerFailureKeepsMessageRetryable();
        duplicateDeliveryCreatesOnlyOneInvoice();
        maxAttemptsMovesToDeadLetter();
        System.out.println("Kapitel3_TESTS_OK");
    }
static void orderAndOutboxAreWrittenTogether() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutboxRepository();
    new PlaceOrderService(orders, outbox).placeOrder("O-1", Money.eur("10.00"), Instant.parse("2026-01-01T00:00:00Z"));
    check(orders.size() == 1, "order saved");
    check(outbox.all().size() == 1, "outbox saved");
    check(outbox.all().getFirst().status() == OutboxStatus.PENDING, "pending");
}
static void brokerFailureKeepsMessageRetryable() {
    var outbox = new InMemoryOutboxRepository();
    var broker = new FakeBroker();
    outbox.save(OutboxMessage.pending("Order", "O-2", "OrderAccepted", 1, "{"orderId":"O-2"}", Instant.parse("2026-01-01T00:00:00Z")));
    broker.failNextPublishes(1);
    var publisher = new OutboxPublisher(outbox, broker, 10, 3);
    publisher.publishDue(Instant.parse("2026-01-01T00:00:00Z"));
    check(outbox.all().getFirst().status() == OutboxStatus.FAILED, "failed after broker error");
    publisher.publishDue(Instant.parse("2026-01-01T00:00:20Z"));
    check(outbox.all().getFirst().status() == OutboxStatus.SENT, "sent after retry");
}
static void duplicateDeliveryCreatesOnlyOneInvoice() {
    var idem = new InMemoryIdempotencyStore();
    var invoices = new InvoiceRepository();
    var consumer = new BillingConsumer(idem, invoices);
    var event = new EventEnvelope(java.util.UUID.randomUUID(), "OrderAccepted", 1, "{"orderId":"O-3"}");
    consumer.onMessage(event);
    consumer.onMessage(event);
    check(invoices.invoices().size() == 1, "only one invoice");
}
static void maxAttemptsMovesToDeadLetter() {
    var outbox = new InMemoryOutboxRepository();
    var broker = new FakeBroker();
    broker.failNextPublishes(5);
    outbox.save(OutboxMessage.pending("Order", "O-4", "OrderAccepted", 1, "{"orderId":"O-4"}", Instant.parse("2026-01-01T00:00:00Z")));
    var publisher = new OutboxPublisher(outbox, broker, 10, 2);
    publisher.publishDue(Instant.parse("2026-01-01T00:00:00Z"));
    publisher.publishDue(Instant.parse("2026-01-01T00:01:00Z"));
    check(outbox.all().getFirst().status() == OutboxStatus.DEAD_LETTER, "dead letter after max attempts");
}
static void check(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
}

Ein produktionsnahes Team würde diese Tests später mit echter Datenbank, echter Transaktionsgrenze und Testcontainers erweitern. Der Lernwert des JDK-only-Labs liegt darin, dass man die Mechanik ohne Framework-Ablenkung versteht.

11. Produktionshinweise und typische Fehler

Typische Fehler bei Outbox-Implementierungen sind selten Syntaxfehler. Häufig sind es Betriebs- und Architekturfehler:

  • Outbox-Tabelle hat keinen passenden Index auf (status, next_attempt_at, created_at).
  • Publisher liest zu große Batches und blockiert die Datenbank.
  • IN_FLIGHT-Nachrichten werden nach Crash nie wieder freigegeben.
  • Consumer ist nicht idempotent und erzeugt doppelte Rechnungen oder doppelte Reservierungen.
  • Event-Payload enthält interne Entity-Strukturen statt stabile externe Verträge.
  • Dead-Letter-Fälle werden nicht überwacht.
  • Retry läuft ohne Backoff und verstärkt Störungen.
  • Cleanup löscht Nachrichten, bevor Audit und Replay-Anforderungen geklärt sind.

Die wichtigste Produktionsregel lautet: Outbox ist ein technisches Muster mit fachlichen Konsequenzen. Wer Events verliert, erzeugt nicht nur ein technisches Ticket, sondern falsche Rechnungen, falsche Bestände, falsches Reporting oder fehlende Audit-Spuren.

12. Checkliste fuer echte Projekte

Vor einer produktiven Outbox sollte man mindestens diese Fragen beantworten:

  1. Welche Events sind fachlich verpflichtend und welche nur informativ?
  2. Welche Tabelle ist die Quelle der Wahrheit?
  3. Wird Outbox in derselben Transaktion geschrieben wie das Aggregate?
  4. Ist der Publisher mehrfach startbar und crash-sicher?
  5. Gibt es Max-Attempts, Backoff und DLQ?
  6. Sind Consumer idempotent?
  7. Gibt es Correlation IDs und Message IDs?
  8. Sind Event-Versionen dokumentiert?
  9. Gibt es Metriken für pending, failed, dead-letter und oldest-message-age?
  10. Gibt es einen Replay-Prozess nach Fehlerbehebung?

Wenn eine dieser Fragen nicht beantwortet ist, ist das System nicht fertig, sondern nur im Happy Path lauffähig.

Code-Labs zu diesem Kapitel

05. Outbox in Produktion und Betrieb

Kapitelkompass

Zuverlässigkeit wird im Betrieb sichtbar

Produktionsreife entsteht durch Lag-Metriken, Retry-Regeln, Cleanup und ein klares Reprocessing-Verfahren.

BacklogAusgangspunktRetryEinordnenAlarmAbsichernCleanupErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Lag und Alter messen
  • Backoff begrenzen
  • Poison Messages isolieren
  • Cleanup sicher planen

Praxisfall

Der Publisher arbeitet, aber alte Nachrichten bleiben liegen und das Backlog wächst.

Entscheidung

Alarmiere auf Alter und Fortschritt, nicht nur auf die absolute Anzahl offener Einträge.

!

Typisches Risiko

Aggressives Cleanup kann die letzte reproduzierbare Spur eines Zustellfehlers entfernen.

Thema: Produktionssicht: Monitoring, Cleanup, Backpressure, Betrieb und Checklisten
Teilblock: 4 von 4 innerhalb Kapitel

1. Warum Produktion ein eigenes Deep-Dive-Thema ist

Kapitel bis 4C.3 haben erklärt, wie Transaktionsgrenzen, Locking und Outbox technisch funktionieren. Kapitel ergänzt die Betriebsrealität: Ein Pattern ist erst dann enterprise-tauglich, wenn es auch bei Last, Ausfällen, Rückstau, Fehlkonfiguration, Schemafehlern und menschlicher Wartung verständlich bleibt.

In vielen Projekten ist die Outbox fachlich korrekt gebaut, aber operativ schwach. Die Tabelle wächst, Retry-Fehler verschwinden im Log, Alerts melden zu spät, Cleanup löscht zu viel oder gar nichts, Publisher skalieren unkontrolliert und im Incident weiß niemand, ob ein Event erneut gesendet werden darf. Deep Dive bedeutet hier: Wir betrachten nicht nur Code, sondern den gesamten Lebenszyklus eines Events in Produktion.

Die wichtigste Regel lautet: Outbox ist kein Fire-and-Forget-Mechanismus. Outbox ist ein kontrolliertes, messbares, wiederholbares Betriebsverfahren.

2. Metriken: Welche Zahlen wirklich wichtig sind

Eine Outbox ohne Metriken ist ein blinder Fleck zwischen Datenbank und Broker. Die Anwendung kann fachlich erfolgreich aussehen, obwohl nachgelagerte Systeme keine Events bekommen. Darum müssen Metriken nicht dekorativ, sondern handlungsorientiert sein.

Wichtige Metriken:

  • pendingCount: Anzahl wartender Events.
  • oldestPendingAge: Alter des ältesten noch nicht final verarbeiteten Events.
  • failedCount: Anzahl retryfähiger Fehler.
  • dlqCount: Anzahl nicht mehr automatisch verarbeitbarer Events.
  • attemptsHistogram: Verteilung der Wiederholungsversuche.
  • publishedPerMinute: realer Durchsatz des Publishers.

Der Unterschied zwischen pendingCount und oldestPendingAge ist wichtig. Ein hoher Pending-Wert kann bei Last normal sein. Ein altes Pending-Event kann dagegen ein Poison-Event sein, das seit Stunden blockiert.

JAVA
MetricSnapshot snapshot = metrics.snapshot(clock.now());
if (snapshot.oldestPendingAge().compareTo(Duration.ofMinutes(30)) > 0) {
    alert("Outbox SLA verletzt");
}

Produktionshinweis: Metriken sollten nach eventType, status, aggregateType und Fehlerklasse gruppierbar sein. Sonst sieht man nur, dass “etwas” kaputt ist, aber nicht, welches fachliche Subsystem betroffen ist.

3. Backpressure: Warum schneller nicht immer besser ist

Ein häufiger Reflex bei wachsender Outbox ist: mehr Publisher-Instanzen starten. Das kann helfen, aber auch schaden. Wenn der Broker langsam ist, der Consumer Fehler produziert oder die Datenbank bereits am Limit ist, verstärkt mehr Parallelität nur das Problem.

Backpressure bedeutet: Das System reagiert auf Betriebszustand. Es reduziert Batchgrößen, pausiert bestimmte Eventtypen, erhöht Retry-Abstände oder verhindert Replay-Stürme.

Die Codebasis nutzt eine Strategy für Batchgrößen:

JAVA
// Pattern: Strategy - Batchgroesse wird aus Betriebszustand berechnet.
public interface BackpressurePolicy {
    int batchLimit(MetricSnapshot snapshot);
}

Diese Regel ist bewusst einfach, aber realistisch:

JAVA
public int batchLimit(MetricSnapshot snapshot) {
    if (snapshot.dlq() > 0) return Math.max(1, normalLimit / 4);
    if (snapshot.failed() > 10) return Math.max(1, normalLimit / 2);
    if (snapshot.oldestPendingAge().compareTo(Duration.ofMinutes(15)) > 0) return normalLimit * 2;
    return normalLimit;
}

Warum nicht immer hochskalieren? Weil ein DLQ-Eintrag oft zeigt, dass es ein strukturelles Problem gibt: Schemafehler, fehlende Berechtigung, inkompatibler Consumer oder ungültige Payload. Mehr Durchsatz macht dann nur schneller mehr kaputte Nachrichten.

4. Cleanup und Retention: Outbox ist kein Archiv

Ohne Cleanup wächst die Outbox-Tabelle unbegrenzt. Das führt zu langsameren Abfragen, größeren Indizes, längeren Backups und schlechteren Deployments. Gleichzeitig darf man nicht zu früh löschen, weil gesendete Events für Debugging, Audit-Korrelation und Replay-Analyse wichtig sein können.

Eine robuste Regel unterscheidet mindestens:

  • SENT: nach Retention löschen oder archivieren.
  • FAILED: behalten, solange automatisch retryfähig.
  • DLQ: niemals blind löschen, sondern bewusst analysieren.
  • PENDING: nicht löschen, sondern Ursache prüfen.
JAVA
public int cleanupSentMessages() {
    return repository.deleteSentOlderThan(clock.now().minus(retention), batchSize);
}

Produktionshinweis: Cleanup sollte batchweise laufen. Ein DELETE FROM outbox WHERE sent_at < ? ohne Limit kann große Locks erzeugen und genau das System destabilisieren, das man entlasten wollte.

5. Alerting: Weniger Lärm, mehr Handlung

Ein Alert ist nur gut, wenn daraus eine Entscheidung folgt. “Outbox Fehler” reicht nicht. Besser ist: “OrderAccepted Events seit 34 Minuten nicht publiziert; Billing und Reporting betroffen; Runbook Schritt 2 ausführen”.

Die Codebasis enthält einen OutboxRunbookEvaluator. Das ist kein Ersatz für Prometheus oder Grafana, aber ein gutes Lernmodell: Betriebszustand wird in eine Entscheidung übersetzt.

JAVA
public RunbookDecision evaluate(MetricSnapshot snapshot) {
    if (snapshot.dlq() > 0) {
        return new RunbookDecision(AlertLevel.CRITICAL,
        "DLQ analysieren, Publisher nicht blind neu starten, Replay-Plan erstellen");
    }
if (snapshot.oldestPendingAge().compareTo(Duration.ofMinutes(30)) > 0) {
    return new RunbookDecision(AlertLevel.CRITICAL,
    "Outbox-Lag verletzt SLA: Broker, Publisher und Consumer pruefen");
}
return new RunbookDecision(AlertLevel.OK, "Kein Eingriff erforderlich");
}

Gute Alerts haben Kontext: Eventtyp, Alter, letzter Fehler, Anzahl Attempts, betroffene Domäne, letzte erfolgreiche Publikation und Link zum Runbook.

6. Runbook: Was im Incident konkret passiert

Ein Runbook verhindert Panik. Es sagt nicht nur, was technisch kaputt sein könnte, sondern in welcher Reihenfolge man sicher prüft.

Beispielablauf:

  1. Fachliche Auswirkung bestimmen: Sind Rechnungen verspätet? Fehlt Reporting? Fehlt Audit?
  2. Outbox nach Status und Eventtyp gruppieren.
  3. Ältestes Pending-Event ansehen.
  4. Fehlerklasse prüfen: Netzwerk, Auth, Schema, fachliche Validierung, Consumer-Bug.
  5. Publisher bei Poison Message nicht blind hochskalieren.
  6. DLQ nur replayen, wenn Consumer idempotent ist.
  7. Nach Incident Tests, Monitoring und Runbook ergänzen.

Ein gefährlicher Fehler ist das manuelle Zurücksetzen aller FAILED-Nachrichten auf PENDING, ohne die Ursache zu verstehen. Dadurch entsteht oft ein Retry-Sturm.

7. Datenbank-Indizes und Query-Formen

Outbox-Performance hängt stark von einfachen, stabilen Queries ab. Typische Publisher-Query:

SQL
SELECT id, aggregate_id, event_type, payload, attempts, next_attempt_at
FROM outbox
WHERE status IN ('PENDING', 'FAILED')
 AND next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY created_at
FETCH FIRST ? ROWS ONLY;

Dazu passen Indizes wie:

SQL
CREATE INDEX idx_outbox_due
ON outbox(status, next_attempt_at, created_at);
CREATE INDEX idx_outbox_sent_cleanup
ON outbox(status, sent_at);

Warum ist das wichtig? Weil eine große Outbox-Tabelle mit falschem Index bei jedem Publisherlauf viele Zeilen scannen kann. Das erzeugt DB-Last, obwohl fachlich wenig passiert. In echten Systemen ist das häufig der Unterschied zwischen stabiler Hintergrundverarbeitung und periodischen Lastspitzen.

Produktionshinweis: Indexdesign gehört zur Outbox-Architektur, nicht zur nachträglichen DBA-Kosmetik.

8. Code-Walkthrough: OutboxPublisher als Betriebs-Use-Case

Der Publisher ist nicht nur ein technischer Loop. Er ist ein eigener Application Service mit Regeln:

  • nur due Messages laden,
  • Batchgröße über Backpressure bestimmen,
  • Status explizit setzen,
  • Brokerfehler in Retry-Zustand übersetzen,
  • Metriken aktualisieren,
  • DLQ nach Max Attempts respektieren.
JAVA
public int publishDueMessages() {
    Instant now = clock.now();
    MetricSnapshot snapshot = metrics.snapshot(now);
    int limit = backpressurePolicy.batchLimit(snapshot);
    metrics.rememberBatchLimit(limit);
    int sent = 0;
    for (OutboxMessage message : repository.dueMessages(now, limit)) {
        message.markInFlight();
        try {
            broker.send(message);
            message.markSent(now);
            sent++;
        }
    catch (BrokerUnavailableException ex) {
        message.markFailed(ex.getMessage(), retryPolicy.nextAttempt(now, message.attempts()), retryPolicy.maxAttempts());
    }
}
return sent;
}

Wichtig: In echter Produktion müsste markInFlight transaktional/atomar claimen, damit mehrere Publisher-Instanzen nicht dieselbe Message gleichzeitig senden. Das Lab zeigt die Logik, nicht den Datenbank-Lock-Mechanismus. Dieser Unterschied wird im Buch bewusst markiert.

9. Tests: Betriebsszenarien statt nur Happy Path

Deep Dive heißt: Tests prüfen nicht nur, ob ein Event gesendet wird. Sie prüfen Betriebsverhalten.

Die Tests in diesem Kapitel decken ab:

  • Retry wird nach Brokerfehler geplant.
  • Nach Max Attempts wandert die Message in DLQ.
  • Cleanup löscht nur alte gesendete Messages.
  • Runbook eskaliert bei altem Lag.
  • Backpressure reduziert Batchgröße bei DLQ.
JAVA
static void runbookEscalatesOldLag() {
    MetricSnapshot snapshot = new MetricSnapshot(12, 0, 0, Duration.ofMinutes(45), 10);
    RunbookDecision decision = new OutboxRunbookEvaluator().evaluate(snapshot);
    require(decision.level() == AlertLevel.CRITICAL, "old lag must be critical");
}

In echten Projekten kommen weitere Tests dazu: parallele Publisher, Datenbank-Lock-Konflikte, Broker-Timeouts, Consumer-Duplikate, Replay, Schema-Migration und Poison Messages.

10. Typische Produktionsfehler

Häufige Fehler:

  • Outbox ohne Index auf status,next_attempt_at,created_at.
  • Cleanup löscht FAILED oder DLQ blind.
  • Publisher skaliert hoch, obwohl Consumer defekt ist.
  • Retry ohne Exponential Backoff.
  • Kein Jitter, dadurch synchronisierte Retry-Wellen.
  • Keine Trennung zwischen technischen und fachlichen Fehlern.
  • Event-Payload enthält zu wenig Kontext für spätere Analyse.
  • Keine Correlation ID über Order, Outbox, Broker und Consumer.
  • Manuelles Replay ohne Idempotenzprüfung.
  • Alert basiert nur auf failedCount, nicht auf oldestPendingAge.

Merksatz: Die Outbox schützt vor Dual-Write, aber nicht vor schlechtem Betrieb.

11. Produktionscheckliste

Vor produktivem Einsatz prüfen:

  1. Gibt es klare Statuswerte: PENDING, IN_FLIGHT, SENT, FAILED, DLQ?
  2. Gibt es eine Max-Attempts-Regel?
  3. Gibt es Backoff mit Jitter?
  4. Sind Publisher-Queries indiziert?
  5. Ist oldestPendingAge messbar?
  6. Gibt es Alerts mit Runbook-Link?
  7. Ist der Consumer idempotent?
  8. Gibt es eine Replay-Prozedur?
  9. Gibt es Cleanup mit Retention?
  10. Werden DLQ-Nachrichten nie blind gelöscht?
  11. Sind Correlation IDs durchgängig?
  12. Gibt es Tests für Broker-Ausfall und Retry?

Wenn eine dieser Fragen offen ist, ist die Outbox zwar ein Code-Pattern, aber noch kein produktionsreifes Betriebsmodell.

12. Abschluss Kapitel

Kapitel ist damit vollständig:

  • Kapitel: Transaktionsgrenzen, Commit/Rollback, Dual-Write.
  • Kapitel: Locking, Isolation, Deadlocks.
  • Kapitel: Outbox, Idempotenz, Retry, Crash-Fälle.
  • Kapitel: Produktionssicht, Monitoring, Cleanup, Backpressure, Betrieb.

Damit ist der Daten-/Konsistenzblock deutlich tiefer als vorher. Der nächste große Themenblock sollte REST/API Design sein, aber jetzt mit derselben Tiefe: nicht nur Controller zeigen, sondern Ressourcenmodell, DTOs, Validation, Problem Details, Idempotency Keys, Versionierung und Fehlerverträge.

Transaktions-Vertiefung: 4 von 4 innerhalb Kapitel.

Code-Labs zu diesem Kapitel

APIs & SecurityREST-Verträge, Validierung, Rollen, Policies und Audit.
06. REST API Design, DTOs und Problem Details

Kapitelkompass

Eine API ist ein langfristiger Vertrag

Gute REST-Schnittstellen trennen Transport, Fachmodell und Fehlervertrag und bleiben unter Evolution kompatibel.

RequestAusgangspunktDTOEinordnenUse CaseAbsichernResponseErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • DTOs bewusst schneiden
  • Problem Details nutzen
  • Pagination stabil halten
  • Kompatibel versionieren

Praxisfall

Ein Client sendet eine alte Repräsentation, während der Server ein neues optionales Feld einführt.

Entscheidung

Additive Änderungen bevorzugen; Versionen nur bei wirklich inkompatiblen Vertragsbrüchen.

!

Typisches Risiko

Entities direkt auszuliefern koppelt Datenbank, Security und API-Evolution aneinander.

Schnelle Einordnung
SignalLesartNächster Schritt
EvolutionOptionales Feld oder neuer LinkAdditiv ändern und alte Clients weiter akzeptieren
PaginationGroße, veränderliche ErgebnismengeStabilen Sortierschlüssel und Cursor bevorzugen
IdempotenzClient wiederholt POST nach TimeoutIdempotency Key mit Ergebnis und Ablaufzeit speichern

REST API Design, DTOs, Validation, Problem Details, API-Versionierung und Idempotency Keys

1. REST API Design: Vertrag statt Controller-Zufall

Praxisproblem

REST APIs werden in Enterprise-Projekten oft als Nebenprodukt eines Controllers gebaut. Am Anfang reicht das. Spaeter entstehen doppelte POST-Wirkungen, unklare Fehlerantworten, Entity-Leaks und inkompatible Client-Integrationen. Der Deep Dive betrachtet REST deshalb als stabilen Vertrag zwischen Systemen, nicht als Sammlung zufaelliger Java-Methoden.

Schlechte Loesung

JAVA
// Schlecht: Entity kommt direkt aus HTTP, Repository wird direkt benutzt, Fehler werden zufaellig.
public HttpResponse create(OrderEntity entity) {
    repository.save(entity);
    return json(200, entity);
}

Bessere Leitidee

Der Controller ist ein Primary Adapter: Er liest HTTP, validiert den API-Vertrag, ruft einen Use Case auf und uebersetzt das Ergebnis in HTTP. Der Domain-Kern bleibt frei von Headern, JSON und Statuscodes.

Diagramm

Checkliste

  • Ist jede Route fachlich benannt?
  • Gibt es DTOs statt Entities?
  • Sind Fehlerantworten stabil?
  • Sind POST-Endpunkte mit fachlicher Wirkung idempotent?
  • Gibt es Correlation IDs und API-Versionierung?

2. Ressourcenmodell und fachliche Commands

Ressource vs Command

POST /orders beschreibt das Anlegen in der Order-Collection. POST /orders/{id}/cancellations beschreibt einen fachlichen Storno-Command. Gute APIs muessen nicht dogmatisch CRUD sein, aber sie sollten die Fachsprache ausdruecken und keine internen Methoden nach aussen spiegeln.

Beispielroute

TEXT
POST   /orders
GET    /orders/{orderId}
PATCH  /orders/{orderId}
POST   /orders/{orderId}/payment-authorizations
POST   /orders/{orderId}/cancellations
GET    /customers/{customerId}/orders?status=PAID&page=0&size=50

Trade-offs

Zu viele Command-Ressourcen werden unuebersichtlich. Zu generische Endpunkte wie /execute sind noch schlimmer: Sie verstecken Vertrag, Fehler und Security. Ein gutes Ressourcenmodell orientiert sich an fachlichen Statusuebergaengen.

3. DTOs, Mapper und Entity-Leaks

Warum DTOs keine Nebensache sind

DTOs schuetzen den externen Vertrag. JPA-Entities enthalten technische Details, Lazy-Loading-Beziehungen und interne Modellierungsentscheidungen. Sobald eine Entity API-Vertrag wird, wird jede Domain-Aenderung zum Client-Risiko.

Code: Mapper

JAVA
package com.example.restapi;
import java.util.List;
// Pattern: Mapper - trennt API-DTOs und Domain-Objekte bewusst.
public final class OrderMapper {
    public OrderLine toDomainLine(CreateOrderLineRequest dto) {
        return new OrderLine(dto.sku(), dto.quantity(), Money.eur(dto.unitPrice()));
    }
public OrderResponse toResponse(Order order) {
    List<OrderLineResponse> lines = order.lines().stream()
    .map(line -> new OrderLineResponse(
    line.sku(),
    line.quantity(),
    line.unitPrice().amount().toPlainString(),
    line.lineTotal().amount().toPlainString()))
    .toList();
    return new OrderResponse(
    order.id().value(),
    order.customerId(),
    order.status().name(),
    order.total().amount().toPlainString(),
    order.version(),
    lines);
}
}

Produktionshinweis

Automapping ist fuer einfache CRUD-Faelle brauchbar. Bei Geld, Status, Berechtigungen, Datenschutz, Aggregates und fachlichen Fehlern sollte Mapping explizit und testbar bleiben.

4. Validation Deep Dive

Mehrschichtige Validation

Validierung beginnt nicht im Domain-Objekt. Header, Version, Content-Type, Idempotency-Key, DTO-Felder, Business-Regeln und Persistence-Constraints sind unterschiedliche Ebenen. Wer alles zu 400 Bad Request macht, verliert wichtige Semantik.

Diagramm

Code: RequestValidator

JAVA
package com.example.restapi;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
// Pattern: Validator - technische Eingabepruefung bleibt ausserhalb des Domain-Kerns.
public final class RequestValidator {
    public List<ValidationError> validate(CreateOrderRequest request) {
        List<ValidationError> errors = new ArrayList<>();
        if (request == null) {
            errors.add(new ValidationError("request", "body is required"));
            return errors;
        }
    if (request.customerId() == null || request.customerId().isBlank()) {
        errors.add(new ValidationError("customerId", "customerId is required"));
    }
if (request.lines() == null || request.lines().isEmpty()) {
    errors.add(new ValidationError("lines", "at least one line is required"));
    return errors;
}
for (int i = 0; i < request.lines().size(); i++) {
    CreateOrderLineRequest line = request.lines().get(i);
    String prefix = "lines[" + i + "]";
    if (line.sku() == null || line.sku().isBlank()) errors.add(new ValidationError(prefix + ".sku", "sku is required"));
    if (line.quantity() <= 0) errors.add(new ValidationError(prefix + ".quantity", "quantity must be positive"));
    try {
        if (new BigDecimal(line.unitPrice()).signum() < 0) errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must not be negative"));
    }
catch (RuntimeException ex) {
    errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must be a decimal number"));
}
}
return errors;
}
}

Fehlerarten

Ebene Beispiel Status Reaktion
Transport Idempotency-Key fehlt 400 Client korrigiert Header
DTO quantity <= 0 400 UI zeigt Feldfehler
Business Kunde gesperrt 409 Prozessalternative
Persistenz Version passt nicht 412/409 neu laden und Konflikt loesen

5. Problem Details und Fehlerkatalog

Fehler als API-Vertrag

Erfolgreiche Responses sind nur die halbe API. Clients brauchen stabile Fehlerstrukturen fuer Validierung, Konflikte, Versionsprobleme und Retry-Entscheidungen. Deshalb gehoert ein Fehlerkatalog zur API-Dokumentation.

Diagramm

Code: ProblemDetailsFactory

JAVA
package com.example.restapi;
import java.util.List;
import java.util.Map;
// Pattern: Factory - zentrale Erzeugung konsistenter Fehlerobjekte.
public final class ProblemDetailsFactory {
    public ProblemDetail validation(String instance, String correlationId, List<ValidationError> errors) {
        return new ProblemDetail(
        "https://errors.example.com/validation-error",
        "Request validation failed",
        400,
        "The request body is syntactically valid JSON but violates API validation rules.",
        instance,
        correlationId,
        errors,
        Map.of("category", "client"));
    }
public ProblemDetail conflict(String instance, String correlationId, String detail) {
    return new ProblemDetail(
    "https://errors.example.com/business-conflict",
    "Business conflict",
    409,
    detail,
    instance,
    correlationId,
    List.of(),
    Map.of("category", "business"));
}
public ProblemDetail preconditionFailed(String instance, String correlationId, String expected, String actual) {
    return new ProblemDetail(
    "https://errors.example.com/precondition-failed",
    "Resource version conflict",
    412,
    "The provided If-Match value does not match the current resource version.",
    instance,
    correlationId,
    List.of(),
    Map.of("expected", expected, "actual", actual));
}
public ProblemDetail unsupportedVersion(String instance, String correlationId, String version) {
    return new ProblemDetail(
    "https://errors.example.com/unsupported-api-version",
    "Unsupported API version",
    406,
    "API version " + version + " is not supported by this endpoint.",
    instance,
    correlationId,
    List.of(),
    Map.of("supported", "2026-01"));
}
}

Fehlerkatalog

Situation Status Typ Client-Reaktion
DTO ungueltig 400 validation-error Eingabe korrigieren
API-Version unbekannt 406 unsupported-api-version Version setzen/Client aktualisieren
Idempotency-Key mit anderem Payload 409 idempotency-conflict neuen Key/Bugfix
ETag passt nicht 412 precondition-failed Ressource neu lesen
fachlicher Konflikt 409 business-conflict Benutzerentscheidung

6. API-Versionierung ohne Chaos

Version als Versprechen

Eine Version sagt: Dieser Vertrag bleibt kompatibel. Ohne Versionierung brechen Clients bei Feldumbenennungen, geaenderter Fehlerstruktur oder Semantikwechseln. Kapitel nutzt X-Api-Version: 2026-01, weil diese Entscheidung testbar und sichtbar ist.

Code: ApiVersion

JAVA
package com.example.restapi;
// Pattern: Version Negotiation - API-Version wird explizit ausgehandelt statt implizit erraten.
public enum ApiVersion {
    V2026_01("2026-01");
    private final String headerValue;
    ApiVersion(String headerValue) {
        this.headerValue = headerValue;
    }
public String headerValue() {
    return headerValue;
}
public static boolean supported(String value) {
    return V2026_01.headerValue.equals(value);
}
}

Strategien

Strategie Beispiel Vorteil Nachteil
URI /v1/orders sichtbar duplizierte Routen
Header X-Api-Version gleiche Ressource gute Doku noetig
Media Type Vendor JSON sauber komplexer
Additiv neue optionale Felder wenig Bruch nicht jede Aenderung ist additiv

7. Idempotency Keys fuer sichere POST-Retries

Warum das wichtig ist

Der Client sendet POST /orders. Die Order wird gespeichert, aber die Antwort geht verloren. Beim Retry darf nicht eine zweite Order entstehen. Ein Idempotency Store merkt sich Key, Payload-Hash und Response. Gleicher Key + gleicher Hash liefert dieselbe Antwort; gleicher Key + anderer Hash ist ein Konflikt.

Diagramm

Code: Store

JAVA
package com.example.restapi;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
public final class InMemoryIdempotencyStore implements IdempotencyStore {
    private final Map<String, IdempotencyRecord> records = new LinkedHashMap<>();
    @Override public Optional<IdempotencyRecord> find(String key) {
        return Optional.ofNullable(records.get(key));
    }
@Override public HttpResponse executeOnce(String key, String requestHash, Supplier<HttpResponse> action) {
    IdempotencyRecord existing = records.get(key);
    if (existing != null) {
        if (!existing.requestHash().equals(requestHash)) {
            return HttpResponse.json(409, "{\"error\":\"idempotency key reused with different payload\"}");
        }
    return existing.response().withHeader("Idempotency-Replayed", "true");
}
HttpResponse response = action.get();
if (response.status() >= 200 && response.status() < 300) {
    records.put(key, new IdempotencyRecord(requestHash, response));
}
return response;
}
}

Grenzen

Idempotency Keys ersetzen keine Datenbank-Constraints, kein Locking und keine Outbox. Sie loesen ein klares API-Problem: Wiederholte POST-Versuche duerfen nicht mehrfach fachliche Wirkung erzeugen.

8. ETag und If-Match gegen verlorene Updates

Problem

Zwei Clients lesen dieselbe Order. Client A aktualisiert sie. Client B aktualisiert spaeter mit altem Stand und ueberschreibt A. ETag und If-Match bringen Optimistic Locking an die API-Grenze.

Diagramm

Code: ETag

JAVA
package com.example.restapi;
// Pattern: Optimistic Offline Lock - API nutzt Version/ETag gegen verlorene Updates.
public record ETag(String value) {
    public static ETag from(Order order) {
        return new ETag("W/\"order-" + order.id().value() + "-v" + order.version() + "\"");
    }
}

HTTP-Status

Bei nicht passendem If-Match ist 412 Precondition Failed praeziser als ein generisches 409. Der Client weiss: Ich habe auf einer veralteten Version gearbeitet und muss neu laden.

9. Pagination, Filtering und Sorting

Listen sind Produktionsrisiko

GET /orders ohne Limit ist in Demos bequem und in Produktion riskant. Daten wachsen, Sortierungen werden teuer, Responses werden gross, und Timeouts nehmen zu.

Mindestregeln

TEXT
GET /orders?customerId=C-100&status=PAID&page=0&size=50&sort=createdAt,desc

size braucht ein hartes Maximum. Sortierfelder muessen allowlisted sein. Filter brauchen passende Indizes. Bei grossen Datenmengen ist Cursor Pagination oft stabiler als Offset Pagination.

Checkliste

  • Maximalgroesse fuer size definiert?
  • stabile Sortierung?
  • Filter dokumentiert?
  • Indizes vorhanden?
  • Cursor statt Offset geprueft?

10. Controller-Code Walkthrough

Rolle des Controllers

Der Controller besitzt nicht die Fachlichkeit. Er prueft HTTP-Regeln, ruft den Use Case auf und baut HTTP-Antworten. Diese Grenze macht Code framework-unabhaengiger und testbarer.

Code: RestOrderController

JAVA
package com.example.restapi;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
// Pattern: Controller/Primary Adapter - uebersetzt HTTP in Use-Case-Aufrufe und zurueck.
public final class RestOrderController {
    private final JsonCodec json;
    private final RequestValidator validator;
    private final ProblemDetailsFactory problems;
    private final OrderApplicationService service;
    private final IdempotencyStore idempotencyStore;
    public RestOrderController(JsonCodec json, RequestValidator validator, ProblemDetailsFactory problems,
    OrderApplicationService service, IdempotencyStore idempotencyStore) {
        this.json = json;
        this.validator = validator;
        this.problems = problems;
        this.service = service;
        this.idempotencyStore = idempotencyStore;
    }
public HttpResponse handle(HttpRequest request) {
    String version = request.header("X-Api-Version").orElse(ApiVersion.V2026_01.headerValue());
    if (!ApiVersion.supported(version)) {
        return problem(problems.unsupportedVersion(request.path(), request.correlationId(), version));
    }
if (request.method() == HttpMethod.POST && request.path().equals("/orders")) return createOrder(request);
if (request.method() == HttpMethod.GET && request.path().startsWith("/orders/")) return getOrder(request);
return HttpResponse.json(404, "{\"error\":\"not found\"}");
}
private HttpResponse createOrder(HttpRequest request) {
    String key = request.header("Idempotency-Key").orElse(null);
    if (key == null || key.isBlank()) {
        return problem(problems.validation(request.path(), request.correlationId(),
        List.of(new ValidationError("Idempotency-Key", "header is required for POST /orders"))));
    }
String requestHash = sha256(request.body());
return idempotencyStore.executeOnce(key, requestHash, () -> executeCreate(request));
}
private HttpResponse executeCreate(HttpRequest request) {
    CreateOrderRequest dto;
    try {
        dto = json.decodeCreateOrder(request.body());
    }
catch (RuntimeException ex) {
    return problem(problems.validation(request.path(), request.correlationId(),
    List.of(new ValidationError("body", "body cannot be parsed"))));
}
List<ValidationError> errors = validator.validate(dto);
if (!errors.isEmpty()) return problem(problems.validation(request.path(), request.correlationId(), errors));
try {
    OrderResponse response = service.placeOrder(dto);
    return HttpResponse.json(201, json.encode(response))
    .withHeader("Location", "/orders/" + response.id())
    .withHeader("ETag", "W/\"order-" + response.id() + "-v" + response.version() + "\"")
    .withHeader("X-Correlation-Id", request.correlationId());
}
catch (RuntimeException ex) {
    return problem(problems.conflict(request.path(), request.correlationId(), ex.getMessage()));
}
}
private HttpResponse getOrder(HttpRequest request) {
    String id = request.path().substring("/orders/".length());
    try {
        Order domain = service.getDomain(new OrderId(id));
        OrderResponse response = service.get(new OrderId(id));
        return HttpResponse.json(200, json.encode(response))
        .withHeader("ETag", ETag.from(domain).value())
        .withHeader("Cache-Control", "no-store")
        .withHeader("X-Correlation-Id", request.correlationId());
    }
catch (RuntimeException ex) {
    return HttpResponse.json(404, "{\"error\":\"order not found\"}");
}
}
public HttpResponse patchCustomerReference(HttpRequest request, OrderId id, String newCustomerId) {
    try {
        String ifMatch = request.header("If-Match").orElse("");
        OrderResponse response = service.updateCustomerReference(id, newCustomerId, ifMatch);
        return HttpResponse.json(200, json.encode(response));
    }
catch (VersionConflictException ex) {
    return problem(problems.preconditionFailed(request.path(), request.correlationId(), ex.expected(), ex.actual()));
}
}
private HttpResponse problem(ProblemDetail detail) {
    return new HttpResponse(detail.status(), Map.of("Content-Type", "application/problem+json"), json.encode(detail));
}
private String sha256(String value) {
    try {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
    }
catch (Exception e) {
    throw new IllegalStateException(e);
}
}
}

Architekturbewertung

Der Controller kennt Idempotency-Key, X-Api-Version, ProblemDetail und ETag. Der Application Service kennt diese HTTP-Details nicht. Genau diese Trennung ist die entscheidende Architekturqualitaet.

11. Tests: API-Vertrag absichern

Nicht nur Happy Path

API-Tests muessen Vertragsfehler pruefen: Validation Problem, Unsupported Version, Idempotency Replay, Idempotency Conflict und ETag Conflict. Gerade diese Faelle entscheiden, ob Clients robust werden.

Code: Test Runner

JAVA
package com.example.restapi;
import java.util.Map;
public final class Kapitel {
    public static void main(String[] args) {
        createsOrderWithIdempotencyKey();
        replaysSamePostWithoutSecondSideEffect();
        rejectsSameKeyWithDifferentPayload();
        returnsValidationProblem();
        rejectsUnsupportedVersion();
        detectsIfMatchConflict();
        System.out.println("Kapitel");
    }
static void createsOrderWithIdempotencyKey() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse response = controller.handle(validPost("k-1"));
    assertEquals(201, response.status(), "create status");
    assertTrue(response.headers().containsKey("Location"), "location header");
    assertTrue(response.body().contains("ACCEPTED"), "accepted body");
}
static void replaysSamePostWithoutSecondSideEffect() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse first = controller.handle(validPost("k-2"));
    HttpResponse second = controller.handle(validPost("k-2"));
    assertEquals(201, second.status(), "replay status");
    assertEquals("true", second.headers().get("Idempotency-Replayed"), "replay header");
    assertEquals(first.body(), second.body(), "same body on replay");
}
static void rejectsSameKeyWithDifferentPayload() {
    RestOrderController controller = RestApiLab.newController();
    controller.handle(validPost("k-3"));
    HttpRequest differentPayload = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "Idempotency-Key", "k-3"), "customer=C-999;lines=SKU-9,1,1.00");
    HttpResponse response = controller.handle(differentPayload);
    assertEquals(409, response.status(), "same key different payload");
}
static void returnsValidationProblem() {
    RestOrderController controller = RestApiLab.newController();
    HttpRequest request = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "Idempotency-Key", "k-4"), "customer=;lines=SKU-1,0,19.99");
    HttpResponse response = controller.handle(request);
    assertEquals(400, response.status(), "validation status");
    assertEquals("application/problem+json", response.headers().get("Content-Type"), "problem content type");
}
static void rejectsUnsupportedVersion() {
    RestOrderController controller = RestApiLab.newController();
    HttpRequest request = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2030-01",
    "Idempotency-Key", "k-5"), "customer=C-100;lines=SKU-1,1,9.99");
    assertEquals(406, controller.handle(request).status(), "unsupported version");
}
static void detectsIfMatchConflict() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse created = controller.handle(validPost("k-6"));
    String location = created.headers().get("Location");
    String id = location.substring("/orders/".length());
    HttpRequest patch = new HttpRequest(HttpMethod.PATCH, location, Map.of(
    "If-Match", "W/\"order-" + id + "-v999\"",
    "X-Correlation-Id", "test-corr"), Map.of(), "");
    HttpResponse response = controller.patchCustomerReference(patch, new OrderId(id), "C-OTHER");
    assertEquals(412, response.status(), "etag conflict");
}
private static HttpRequest validPost(String key) {
    return HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "X-Correlation-Id", "corr-" + key,
    "Idempotency-Key", key), "customer=C-100;lines=SKU-1,2,19.99|SKU-2,1,5.00");
}
private static void assertEquals(Object expected, Object actual, String message) {
    if (!java.util.Objects.equals(expected, actual)) {
        throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
    }
}
private static void assertTrue(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
}

Produktionscheckliste

  • POST mit fachlicher Wirkung braucht Idempotency-Key.
  • Problem Details muessen stabil sein.
  • DTOs duerfen keine Entities leaken.
  • Updates brauchen Versionsschutz.
  • Listen brauchen Limits.
  • Correlation IDs gehoeren in Response und Logs.
  • API-Versionierung muss getestet werden.

Code-Labs zu diesem Kapitel

07. Security, Rollen, Permissions und Audit

Kapitelkompass

Zugriff als nachvollziehbare Entscheidung

Authentifizierung liefert Identität; Autorisierung bewertet Kontext, Ressource, Mandant und konkrete Berechtigung.

IdentitätAusgangspunktPolicyEinordnenEntscheidungAbsichernAuditErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • AuthN und AuthZ trennen
  • Policies explizit machen
  • Mandanten isolieren
  • Audit beweiskräftig halten

Praxisfall

Ein Support-Mitarbeiter darf Metadaten sehen, aber keine Zahlungsdetails eines fremden Mandanten.

Entscheidung

Berechtigungen an fachlichen Aktionen ausrichten, nicht an zufälligen Controller-Methoden.

!

Typisches Risiko

Breite ADMIN-Rollen umgehen Ownership und erschweren spätere Least-Privilege-Regeln.

Security Deep Dive: Authentifizierung, Autorisierung, Rollen, Permissions, OAuth2/OIDC-Mentalmodell, JWT, Service-to-Service, Audit und sichere Fehlerantworten.

1. Warum Security im Enterprise-System selten nur Login ist

In einfachen Tutorials bedeutet Security oft: Benutzer meldet sich an, danach wird ein Controller mit @RolesAllowed("ADMIN") geschuetzt. In echten Enterprise-Systemen reicht das fast nie. Eine Order-Plattform muss unterscheiden, ob ein Kunde seine eigene Bestellung liest, ob Support fremde Bestellungen lesen darf, ob Billing Rechnungen stornieren darf, ob ein Payment-Service Zahlungen autorisieren darf und ob Audit-Logs nur fuer Revisionsrollen sichtbar sind.

Das zentrale Missverstaendnis lautet: Authentifizierung ist nicht Autorisierung. Authentifizierung sagt, wer der Akteur ist. Autorisierung sagt, ob dieser Akteur diese konkrete Aktion auf dieser konkreten Ressource ausfuehren darf. Eine Rolle ist dabei nur ein grober Baustein. Die eigentliche Entscheidung braucht meistens Ressource, Mandant, Besitzer, Status, Scope, Zeit, Risiko und Aktion.

Schlechte naive Loesung

JAVA
if (user.role().equals("ADMIN")) {
    return orderRepository.findById(orderId);
}
throw new ForbiddenException();

Diese Loesung wirkt einfach, aber sie ist fachlich zu grob. Sie beantwortet nicht, ob der Kunde seine eigene Bestellung sehen darf, ob Support nur lesen, aber nicht stornieren darf, ob Billing andere Rechte braucht und ob ein fremder Mandant versteckt werden muss.

Bessere Denkweise

JAVA
AccessDecision decision = orderReadPolicy.evaluate(securityContext, order);

Die Policy sieht den Akteur, die Ressource und die Aktion. Dadurch kann sie entscheiden: Kunde liest eigene Bestellung, Support liest beliebige Bestellung, fremder Mandant wird als 404 Not Found verborgen, Admin bekommt Sonderrechte.

2. Authentifizierung: Identitaet, Claims und SecurityContext

Authentifizierung besteht nicht nur aus dem Pruefen eines Tokens. Ein Enterprise-System muss einen technischen Nachweis in einen fachlichen Kontext umwandeln. Dieser Kontext enthaelt Subjekt, Tenant, Rollen, Scopes, Authentifizierungszeitpunkt, Remote-Adresse und Correlation ID.

JAVA
public record SecurityPrincipal(
String subject,
String tenantId,
Set<Role> roles,
Set<String> scopes,
Instant authenticatedAt
) {
}

Der SecurityContext ist bewusst fachlich. Controller, Application Services und Policies sollen nicht direkt mit rohen JWT-Strings arbeiten. Token sind Transportartefakte. Fachliche Sicherheitsentscheidungen brauchen normalisierte Akteure.

Token-Validierungspipeline

Eine robuste Validierung prueft mindestens:

  • Signatur oder Token-Introspection
  • Issuer
  • Audience
  • Ablaufzeit
  • Rollen-/Scope-Mapping
  • Tenant-Zuordnung
  • optional: Token-Typ, Key-ID, Rotation, Revocation, Session-Kontext

Didaktischer Code im Lab

Das Lab verwendet bewusst einen kleinen HMAC-basierten Token-Service. Das ist kein Produktions-JWT-Framework, sondern ein Lernmodell. Es macht sichtbar, welche Schritte ein Framework spaeter fuer dich uebernimmt.

JAVA
TokenClaims claims = tokenService.validate(token, clock.instant());
SecurityPrincipal principal = new SecurityPrincipal(
claims.subject(), claims.tenantId(), claims.roles(), claims.scopes(), clock.instant());

Wichtig: In Produktion sollte man etablierte JOSE/OIDC-Bibliotheken und die Security-Funktion des Frameworks verwenden. Die fachliche Entscheidung bleibt trotzdem deine Aufgabe.

3. RBAC, Permissions, Ownership und Tenant-Grenzen

RBAC ist gut fuer grobe Verantwortungen. Permissions sind besser fuer konkrete Aktionen. Ownership ist notwendig, wenn Benutzer eigene Ressourcen sehen oder aendern duerfen. Tenant-Grenzen verhindern, dass Daten zwischen Mandanten sichtbar werden.

Beispielrollen

JAVA
public enum Role {
    CUSTOMER(EnumSet.of(Permission.ORDER_READ_OWN, Permission.ORDER_CANCEL_OWN)),
    SUPPORT(EnumSet.of(Permission.ORDER_READ_ANY)),
    BILLING(EnumSet.of(Permission.INVOICE_READ_ANY, Permission.INVOICE_CANCEL)),
    PAYMENT_SERVICE(EnumSet.of(Permission.PAYMENT_AUTHORIZE)),
    AUDITOR(EnumSet.of(Permission.AUDIT_READ)),
    ADMIN(EnumSet.of(Permission.ADMIN_ALL));
}

Eine Rolle wie SUPPORT darf in diesem Modell Bestellungen lesen, aber nicht automatisch Zahlungen ausloesen oder Rechnungen stornieren. Das verhindert Rechteausweitung durch zu breite Rollen.

Ownership-Regel

JAVA
boolean isOwner = principal.subject().equals(order.owner().value());
if (isOwner && principal.hasPermission(Permission.ORDER_READ_OWN)) {
    return AccessDecision.allowed("owner grants ORDER_READ_OWN");
}

Diese Regel ist fachlich: Der Kunde darf seine eigene Bestellung lesen. Das ist nicht dasselbe wie eine globale ORDER_READ_ANY-Berechtigung.

Tenant-Grenze

JAVA
if (!principal.tenantId().equals("tenant-main")) {
    return AccessDecision.denied("wrong tenant", DenialVisibility.HIDE_AS_NOT_FOUND);
}

Ein fremder Tenant bekommt bewusst nicht 403, sondern kann als 404 verborgen werden. Das reduziert Information Disclosure: Der Client lernt nicht, ob die Ressource existiert.

4. Policy statt versteckte Controller-Logik

Autorisierung in Controllern wird schnell unwartbar. Heute steht dort if support, morgen kommt Tenant-Pruefung, danach Status-Regel, danach Audit und Sonderfall fuer Compliance. Besser ist eine ausdrueckliche Policy.

JAVA
public interface AccessPolicy<T> {
    AccessDecision evaluate(SecurityContext context, T resource);
}

Die Policy ist testbar. Sie kann in REST, Messaging, Batchjobs und internen Services wiederverwendet werden. Sie entkoppelt Security-Regeln von Transporttechnik.

Policy-Entscheidung als fachliches Ergebnis

JAVA
public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDecision.Denied {
    record Allowed(String reason) implements AccessDecision {
    }
record Denied(String reason, DenialVisibility visibility) implements AccessDecision {
}
}

Der Grund der Entscheidung ist fuer Audit wichtig. Die Sichtbarkeit ist fuer sichere Fehlerantworten wichtig. Nicht jede Ablehnung sollte dem Client gleich viel verraten.

5. Sichere Fehlerantworten: 401, 403, 404

Security-Fehler sind nicht nur HTTP-Statuscodes. Sie sind ein Teil des Sicherheitsdesigns.

Status Bedeutung Typischer Fall Vorsicht
401 nicht authentifiziert Token fehlt, ungueltig oder abgelaufen keine Signaturdetails leaken
403 authentifiziert, aber nicht erlaubt Benutzer ist bekannt, Permission fehlt keine internen Policy-Namen leaken
404 Ressource nicht sichtbar oder nicht vorhanden fremder Tenant, fremde private Ressource bewusst als Schutz gegen Enumeration

Problem Details

JAVA
return new ProblemDetails(
403,
"https://errors.example.com/forbidden",
"Forbidden",
"You are not allowed to perform this action.",
correlationId,
Map.of());

Die Antwort ist stabil, knapp und sicher. Interne Gruende bleiben im Audit, nicht in der API-Antwort.

6. Audit: Jede relevante Security-Entscheidung muss rekonstruierbar sein

Audit ist nicht nur Logging. Ein Audit-Ereignis muss spaeter beantworten koennen: Wer hat was auf welcher Ressource versucht, wann, mit welcher Entscheidung und welcher Correlation ID?

JAVA
public record AuditEvent(
Instant occurredAt,
String correlationId,
String actor,
String action,
String resource,
String decision,
String reason,
String remoteAddress
) {
}

Was nicht ins Audit gehoert

  • komplette Tokens
  • Passwoerter
  • private personenbezogene Rohdaten ohne Zweck
  • Payment-Geheimnisse
  • ganze Request-Bodies mit sensiblen Daten

Was ins Audit gehoert

  • Actor/Subjekt
  • technische oder fachliche Aktion
  • Ressourcen-ID
  • Entscheidung ALLOW/DENY
  • sicherer Grund
  • Correlation ID
  • Zeitpunkt
  • System-/Service-Identitaet

7. Service-to-Service Security

In Enterprise-Systemen sprechen Services miteinander. Ein User-Token ist nicht automatisch ein Service-Token. Ein Payment-Service, der eine Zahlung autorisiert, braucht eigene Identitaet, eigene Scopes und eigene Auditierbarkeit.

Typische Varianten

Variante Zweck Risiko
User Token weiterreichen User-Kontext bleibt erhalten nicht jeder Service darf alles mit User-Rechten tun
On-behalf-of Service handelt kontrolliert fuer Benutzer komplexere Token- und Scope-Regeln
Client Credentials Service handelt als Service kein User-Kontext, Audit braucht Service-Actor
mTLS technische Service-Identitaet ersetzt nicht fachliche Autorisierung

Scope Policy

JAVA
public final class ServiceTokenPolicy implements AccessPolicy<String> {
    private final String requiredScope;
    public AccessDecision evaluate(SecurityContext context, String action) {
        return context.principal()
        .filter(principal -> principal.hasScope(requiredScope))
        .map(principal -> AccessDecision.allowed("service scope grants " + requiredScope))
        .orElseGet(() -> AccessDecision.denied("missing service scope " + requiredScope,
        DenialVisibility.SHOW_FORBIDDEN));
    }
}

Scopes beschreiben technische API-Rechte. Sie sollten nicht blind mit fachlichen Permissions gleichgesetzt werden.

8. Security im Zusammenspiel mit REST

Aus Kapitel kommt der API-Vertrag. Kapitel fuegt die Sicherheitsentscheidung hinzu. Der Request-Flow wird damit:

TEXT
HTTP Request
  -> Correlation ID
  -> Token aus Authorization Header
  -> Token validieren
  -> SecurityContext erzeugen
  -> DTO validieren
  -> Ressource laden
  -> Policy pruefen
  -> Use Case ausfuehren
  -> Audit schreiben
  -> sichere Response erzeugen

Wichtig ist die Reihenfolge. Manchmal muss eine Ressource geladen werden, bevor Ownership geprueft werden kann. Dabei darf aber kein Datenleck entstehen. Repository-Zugriff, Policy und Fehlerabbildung muessen gemeinsam geplant werden.

9. Typische Fehler und Anti-Patterns

Anti-Pattern: Rollen als Strings quer im Code

TEXT
if (role.equals("ADMIN") || role.equals("SUPER_USER")) { ... }

Problem: schwer testbar, schwer refactorbar, keine zentrale Permission-Sicht.

Anti-Pattern: Security nur im Controller

Wenn Messaging-Consumer oder Batchjobs dieselbe Use-Case-Logik ohne Controller ausfuehren, werden Regeln umgangen.

Anti-Pattern: 403 fuer fremde private Ressourcen

Ein Angreifer kann IDs raten und durch 403 erkennen, dass eine Ressource existiert.

Anti-Pattern: Token-Claims blind vertrauen

Claims muessen zu Issuer, Audience, Ablaufzeit, Tenant und Signatur passen. Sonst akzeptiert das System fremde oder abgelaufene Tokens.

Anti-Pattern: Audit erst nach erfolgreicher Aktion

Auch verweigerte Zugriffe sind sicherheitsrelevant.

10. Teststrategie fuer Security-Regeln

Security braucht eigene Tests. Controller-Tests allein sind zu grob. Gute Security-Tests pruefen Policies, Fehlerabbildung, Audit und Service-Scopes.

Im Lab enthalten:

TEXT
- Owner darf eigene Bestellung lesen
- fremder Kunde bekommt NotFound
- Support darf beliebige Bestellung lesen
- ungueltiges Token wird 401
- abgelaufenes Token wird 401
- Allow und Deny erzeugen Audit Events
- Service-Scope erlaubt Payment-Aktion

Beispieltest

JAVA
SecurityContext stranger = f.context("customer-2", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
assertThrows(NotFoundException.class,
() -> f.query.getOrder(stranger, new OrderId("ORD-1")),
"foreign customer hidden as not found");

Dieser Test ist fachlich wichtiger als ein reiner HTTP-Test. Er prueft die echte Regel: Fremde Ressource wird verborgen.

11. Produktionshinweise

  • Token-Keys rotieren und Key-ID beachten.
  • Issuer und Audience strikt pruefen.
  • Admin-Rechte minimieren und auditieren.
  • Service-Accounts nicht mit Benutzerrollen vermischen.
  • Secrets niemals in Logs schreiben.
  • Audit manipulationssicher speichern.
  • 401/403/404 bewusst designen.
  • Rate Limiting fuer Login, Token-Endpoints und sensible Aktionen einplanen.
  • Correlation ID durch alle Services propagieren.
  • Rechteaenderungen versionieren und testbar halten.

12. Checkliste

TEXT
[ ] Gibt es eine klare Trennung zwischen Authentifizierung und Autorisierung?
[ ] Sind Rollen, Permissions, Scopes und Ownership sauber getrennt?
[ ] Sind Tenant-Grenzen testbar?
[ ] Gibt es Policies statt verstreuter if-Abfragen?
[ ] Werden Deny-Entscheidungen auditiert?
[ ] Leaken Fehlerantworten keine internen Details?
[ ] Werden Service-Identitaeten getrennt von User-Identitaeten behandelt?
[ ] Gibt es Tests fuer Owner, Support, Billing, Service und fremde Tenants?
[ ] Ist die Security-Logik ausserhalb des Controllers wiederverwendbar?

13. Code-Walkthrough

Der wichtigste Pfad im Lab ist:

TEXT
SecurityContextFactory
  -> SignedTokenService.validate
  -> OrderQueryService.getOrder
  -> AuthorizationService.require
  -> OrderReadPolicy.evaluate
  -> AuditSink.append
  -> SecurityExceptionMapper.toProblem

Die Architekturabsicht ist bewusst: Token-Technik ist am Rand, Policy ist im Application-Bereich, Audit ist ein Port, Fehlerantworten werden gemappt.

14. Lernaufgaben

  1. Erweitere OrderCancelPolicy: Kunde darf eigene Bestellung nur stornieren, wenn Status NEW oder CONFIRMED ist.
  2. Fuege eine TenantPolicy hinzu, die Tenant-Pruefungen zentralisiert.
  3. Erweitere Audit um clientId fuer Service-Accounts.
  4. Schreibe einen Test, der verhindert, dass TokenValidationException-Details in Problem Details auftauchen.
  5. Modellieren SUPPORT_READ_ONLY und SUPPORT_MANAGER als getrennte Rollen.

15. Zusammenfassung

Kapitel vertieft Security nicht als Framework-Konfiguration, sondern als Architekturentscheidung. Authentifizierung liefert Identitaet. Autorisierung prueft konkrete Aktionen auf konkrete Ressourcen. Policies machen Regeln sichtbar. Audit macht Entscheidungen rekonstruierbar. Sichere Fehlerantworten verhindern Informationslecks. Service-to-Service-Security trennt technische Dienstidentitaet von Benutzeridentitaet.

Code-Labs zu diesem Kapitel

MessagingEvent Design, Retry, DLQ, Reprocessing, Monitoring und Backpressure.
08. Messaging, Kafka/JMS und Event Design

Kapitelkompass

Events sind Verträge in Bewegung

Messaging entkoppelt Zeit und Verfügbarkeit, verlangt dafür klare Event-Semantik, Partitionierung und Schema-Evolution.

ProducerAusgangspunktTopicEinordnenPartitionAbsichernConsumerErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Event und Command trennen
  • Keys sinnvoll wählen
  • Reihenfolge einordnen
  • Schemas evolvieren

Praxisfall

OrderCreated muss pro Bestellung geordnet, aber über Bestellungen parallel verarbeitet werden.

Entscheidung

Partitioniere nach der fachlichen Einheit, für die Reihenfolge tatsächlich garantiert sein muss.

!

Typisches Risiko

Ein Event mit Datenbanktabellen-Semantik verteilt internes Design als dauerhaften Vertrag.

Messaging Deep Dive: Kafka/JMS, Event Design, Commands vs Events, Topics, Partitionen, Consumer Groups und Schema-Versionierung.

1. Warum Messaging ein Architekturthema ist

Messaging wird in Enterprise-Projekten oft zu spät ernst genommen. Ein Team beginnt mit sendEvent(order) und merkt erst im Betrieb, dass ein Event nicht nur eine technische Nachricht ist. Ein Event ist ein Vertrag zwischen fachlichem Modell, Laufzeitverhalten und Betrieb. Sobald ein Order-Service einen Billing-Service, ein Reporting-System, ein Audit-System oder eine Legacy-Schnittstelle asynchron beliefert, entstehen neue Designfragen.

Die wichtigste Erkenntnis ist: Messaging entfernt Kopplung nicht, sondern verschiebt sie. Ein synchroner REST-Call koppelt den Aufrufer an Antwortzeit und Verfügbarkeit. Ein asynchrones Event koppelt Publisher und Consumer an Event-Schema, fachliche Bedeutung, Reihenfolge, Wiederholbarkeit und Reprocessing-Strategie.

Synchrone und asynchrone Integration

Schlechte Enterprise-Lösung:

JAVA
public void placeOrder(PlaceOrderCommand command) {
    Order order = repository.save(command.toOrder());
    kafka.send("order-events", order.toJson());
    // keine Version, keine Idempotenz, kein Trace
}

Diese Lösung sieht kurz aus, aber sie verschweigt fast alles, was im Betrieb wichtig wird: Was passiert, wenn der Broker down ist? Ist toJson() ein stabiler Vertrag? Welcher Consumer darf welche Felder verwenden? Kann das Event erneut verarbeitet werden? Wie wird eine alte Event-Version gelesen? Ein Deep-Dive-Design muss diese Fragen vor dem ersten produktiven Release beantworten.

2. Synchron, asynchron und fachliche Grenzen

Synchron bedeutet nicht automatisch schlecht und asynchron bedeutet nicht automatisch modern. Eine Bestellung anzulegen ist für den Benutzer oft ein synchroner Vorgang: Der Client will wissen, ob die Order angenommen wurde. Die spätere Rechnungserzeugung, das Reporting oder eine Benachrichtigung können dagegen asynchron laufen.

Eine robuste Faustregel lautet: Die unmittelbare fachliche Entscheidung bleibt im Application Service. Nebenwirkungen, Projektionen und nachgelagerte Reaktionen können über Events laufen. Wenn aber ein Consumer sofort antworten muss, ist Messaging meist das falsche Werkzeug oder braucht ein explizites Request-Reply-Protokoll.

ASCII-Sicht:

TEXT
Client
  |
  | POST /orders
  v
Order Application Service  -- lokale Entscheidung --> Order accepted/rejected
  |
  | OrderPlaced Event
  v
Broker/Log
  |------------------> Billing Projection
  |------------------> Reporting
  |------------------> Audit

Produktionshinweis: Wenn ein Fachbereich sagt, „Billing muss garantiert danach passieren“, bedeutet das nicht automatisch synchrone Kopplung. Es bedeutet, dass Fehlerpfad, Retry, Monitoring und Eskalation sauber sein müssen.

3. Command, Event und Query wirklich unterscheiden

Commands, Events und Queries sind keine Namenskonventionen, sondern unterschiedliche fachliche Aussagen.

Command Event Query

Ein Command ist eine Aufforderung. Es hat einen intendierten Empfänger und kann abgelehnt werden. Ein Event ist eine Tatsache, die bereits passiert ist. Eine Query liest Daten und sollte keine fachliche Änderung auslösen.

Schlechtes Beispiel:

JAVA
// klingt wie Tatsache, ist aber eigentlich eine Anweisung an Billing
public record InvoiceShouldBeGeneratedEvent(String orderId) {
}

Besser:

JAVA
public record GenerateInvoiceCommand(String orderId) {
}
public record InvoiceGenerated(String invoiceId, String orderId) {
}

Warum das wichtig ist: Wenn ein Consumer InvoiceShouldBeGeneratedEvent liest, weiß er nicht, ob eine Rechnung schon existiert, ob er die Aktion ausführen soll oder ob es nur eine Zustandsinformation ist. Unklare Nachrichtentypen erzeugen unklare Verantwortlichkeiten.

4. Event-Namen und fachliche Granularität

Ein häufiges Anti-Pattern ist das Event OrderChanged. Es klingt flexibel, ist aber für Consumer schlecht. Der Consumer muss herausfinden, was sich geändert hat und ob diese Änderung für ihn relevant ist. Dadurch wandert Fachlogik aus dem Publisher in viele Consumer.

Besser sind konkrete fachliche Ereignisse:

JAVA
public record OrderPlaced(String orderId, String customerId, long amountInCent, String currency) {
}
public record OrderCancelled(String orderId, String reason) {
}
public record PaymentAuthorized(String orderId, String authorizationId) {
}
public record InvoiceGenerated(String invoiceId, String orderId) {
}

Zu fein ist aber ebenfalls gefährlich. OrderLineQuantityFieldChanged ist meistens zu nah an interner Datenstruktur. Ein gutes Event beschreibt eine relevante fachliche Tatsache, nicht eine UI- oder Tabellenänderung.

Checkfrage: Kann ein Fachbereich den Eventnamen verstehen? Falls nein, ist das Event vermutlich zu technisch. Falls ein Consumer aus dem Namen nicht erkennen kann, warum er reagieren sollte, ist es vermutlich zu grob.

5. JMS vs Kafka: verschiedene Denkmodelle

JMS und Kafka werden oft als austauschbare Messaging-Technologien betrachtet. Das ist zu oberflächlich. JMS ist historisch stark auf Nachrichtenzustellung, Queues, Topics, Acknowledgement und Enterprise-Integration ausgerichtet. Kafka ist ein verteiltes append-only Log, in dem Consumer ihre Lesepositionen über Offsets verwalten und Eventströme erneut gelesen werden können.

JMS und Kafka Mentalmodell

JMS passt oft gut für Arbeitsaufträge, Punkt-zu-Punkt-Verarbeitung und klassische Enterprise-Messaging-Szenarien. Kafka passt gut für Event Streaming, Reprocessing, mehrere unabhängige Consumer Groups und hohe Durchsatz-Szenarien.

Eine Migration von JMS nach Kafka ist deshalb keine reine Dependency-Änderung. Das Team muss klären: Wer besitzt Offsets? Wie lange bleiben Events lesbar? Können Consumer neu aufbauen? Wie wird Reihenfolge garantiert? Welche Semantik hat ein Commit?

6. Topic Design und Event-Kategorien

Topic Design ist Architekturdesign. Zu wenige Topics führen zu unklaren Eventströmen. Zu viele Topics erzeugen Betriebskomplexität. Ein Topic sollte fachlich und betrieblich zusammenpassen.

Mögliche Varianten:

Variante Beispiel Vorteil Risiko
Domänen-Topic order-events fachlich verständlich viele Eventtypen in einem Strom
Eventtyp-Topic order-placed leicht gezielt zu abonnieren viele Topics
Kontext-Topic billing-events bounded context nah Grenzfälle bei übergreifenden Flows

Für eine Order/Billing-Plattform ist order-events ein guter Start, wenn Partition Key, Schema-Strategie und Consumer-Vertrag sauber beschrieben sind. Für hochvolumige oder sicherheitskritische Events kann ein eigenes Topic sinnvoll sein.

7. Partitionen, Offsets und Consumer Groups

Kafka-artige Systeme skalieren über Partitionen. Innerhalb einer Partition ist die Reihenfolge stabil. Zwischen Partitionen gibt es keine globale Reihenfolge. Consumer Groups erlauben parallele Verarbeitung: Jede Partition wird innerhalb einer Group typischerweise nur von einem Consumer gleichzeitig gelesen.

Topic Partition Offset

Das wichtigste Missverständnis: Ein Offset ist keine fachliche Verarbeitungsgarantie. Wenn ein Consumer den Offset zu früh committed, kann eine Nachricht technisch als gelesen gelten, obwohl die fachliche Wirkung nicht dauerhaft gespeichert wurde.

Gute Regel: Offset-Commit erst nach idempotenter und dauerhafter Verarbeitung. Bei Consumer-Projektionen heißt das: Projektion speichern, Idempotenz markieren, dann Offset committen.

8. Partition Key: Ordnung gegen Parallelität

Der Partition Key entscheidet, welche Events gemeinsam geordnet werden. Wenn alle Order-Events mit orderId partitioniert werden, sieht ein Consumer für dieselbe Order die Reihenfolge OrderPlaced -> PaymentAuthorized -> InvoiceGenerated innerhalb derselben Partition.

Partition Key Ordering

Schlecht:

JAVA
String partitionKey = UUID.randomUUID().toString();

Besser:

JAVA
String partitionKey = event.aggregateId();
// z.B. orderId

Trade-off: Ein sehr grober Key wie tenantId kann Hot Partitions erzeugen. Ein zu zufälliger Key zerstört fachliche Ordnung. Der richtige Key hängt vom Aggregat und vom wichtigsten Reihenfolgeversprechen ab.

9. Event Envelope: Metadaten sind kein Luxus

Ein Event ohne Envelope ist im Betrieb schwer zu verstehen. Der Envelope trennt fachliche Payload von technischen und organisatorischen Metadaten.

Event Envelope

Wichtige Felder:

JAVA
public record EventEnvelope(
String eventId,
String traceId,
String aggregateType,
String aggregateId,
String eventType,
int schemaVersion,
Instant occurredAt,
String partitionKey,
Map<String, String> headers,
Object payload
) {
}

eventId ermöglicht Idempotenz. traceId verbindet Logs über Services. schemaVersion macht Evolution explizit. partitionKey dokumentiert die Ordnungsentscheidung. Ohne diese Felder wird jeder spätere Fehlerfall manuelle Detektivarbeit.

10. Schema Evolution und Consumer-Kompatibilität

Events sind langlebige Verträge. Ein Consumer kann heute ein Event lesen, das gestern, letzte Woche oder vor einer Migration geschrieben wurde. Deshalb darf ein Publisher ein Event-Schema nicht wie eine interne DTO-Klasse behandeln.

Schema Evolution

Kompatible Änderung:

JAVA
// V1
public record OrderPlacedV1(String orderId, long amountInCent) {
}
// V2: neues Feld mit Default möglich
public record OrderPlacedV2(String orderId, long amountInCent, String currency) {
}

Brechende Änderung:

JAVA
// amount wird plötzlich decimal string statt cent long
public record OrderPlacedV3(String orderId, String amount) {
}

Deep-Dive-Regel: Neue Consumer sollten mehrere Versionen lesen können. Neue Publisher sollten nicht sofort alte Consumer brechen. Ereignisse brauchen ein Evolutionsfenster.

11. Delivery Semantics: at-most-once, at-least-once, exactly-once

Delivery Semantics werden häufig missverstanden. At-most-once vermeidet Duplikate, kann aber Nachrichten verlieren. At-least-once vermeidet Verlust, kann aber Duplikate erzeugen. Exactly-once ist in der Praxis nur innerhalb enger technischer Grenzen gültig und löst nicht automatisch fachliche Idempotenz.

Für Enterprise-Consumer ist at-least-once + idempotenter Consumer meist die ehrlichste Annahme.

JAVA
if (idempotencyStore.alreadyProcessed(event.eventId())) {
    return ProcessingResult.duplicateIgnored(event.eventId());
}
applyBusinessEffect(event);
idempotencyStore.markProcessed(event.eventId());

Wichtig: Idempotenz muss dieselbe dauerhafte Grenze haben wie die fachliche Wirkung. Wenn Projektion und Idempotenz in getrennten Speichern ohne Transaktion liegen, bleibt ein Crash-Fenster.

12. Consumer-Verarbeitung als State Machine

Ein Consumer ist kein forEach(event -> handle(event)). Er ist eine kleine State Machine mit technischen und fachlichen Zuständen.

Consumer State Machine

Typischer Ablauf:

TEXT
Received
  -> validate envelope
  -> validate schema version
  -> check idempotency
  -> apply business effect
  -> mark idempotent
  -> commit offset

Fehler können an jeder Stelle auftreten. Deshalb muss der Code unterscheiden: kaputtes Event, temporärer DB-Fehler, unbekannte Version, Duplikat, fachlich nicht mehr relevante Nachricht.

13. Code-Walkthrough: InMemoryKafkaBroker

Das Code-Lab enthält keinen echten Kafka-Client, sondern ein bewusst kleines Modell, das die Konzepte sichtbar macht: Topics, Partitionen, Offsets und Consumer Groups. Dadurch kann man die Logik mit reinem JDK 21 kompilieren und testen.

Ausschnitt:

JAVA
public List<ConsumerRecord> poll(String topicName, String groupId, int maxRecords) {
    Topic topic = topics.get(topicName);
    ConsumerGroupState group = groups.computeIfAbsent(groupId, ConsumerGroupState::new);
    List<ConsumerRecord> records = new ArrayList<>();
    for (Partition partition : topic.partitions()) {
        long nextOffset = group.nextOffset(topicName, partition.id());
        records.addAll(partition.readFrom(nextOffset, maxRecords - records.size()));
    }
return records;
}

Der Code zeigt bewusst: Consumer Groups besitzen eigene Offsets. Zwei Gruppen können dieselben Events unabhängig lesen.

14. Code-Walkthrough: EventSchemaRegistry

Die Registry im Lab ist klein, aber fachlich wichtig. Sie verhindert, dass ein Consumer ungeprüft unbekannte Versionen verarbeitet.

JAVA
public CompatibilityCheck validate(EventEnvelope event) {
    Set<Integer> versions = acceptedVersions.get(event.eventType());
    if (versions == null) return CompatibilityCheck.unknownType(event.eventType());
    if (!versions.contains(event.schemaVersion())) {
        return CompatibilityCheck.rejectedVersion(event.eventType(), event.schemaVersion());
    }
return CompatibilityCheck.accepted(event.eventType(), event.schemaVersion());
}

In echten Projekten kann dahinter Avro, JSON Schema, Protobuf oder ein Schema Registry Service stehen. Das Mentalmodell bleibt gleich: Consumer entscheiden bewusst, welche Verträge sie akzeptieren.

15. Code-Walkthrough: Idempotenter Projection Consumer

Der BillingProjectionConsumer baut aus OrderPlaced-Events eine eigene Sicht. Er verarbeitet Duplikate nicht erneut, lehnt unbekannte Versionen ab und schreibt kaputte Events in eine Dead Letter Queue.

JAVA
public ProcessingResult handle(EventEnvelope event) {
    if (idempotencyStore.alreadyProcessed(event.eventId())) {
        return ProcessingResult.duplicateIgnored(event.eventId());
    }
CompatibilityCheck check = schemaRegistry.validate(event);
if (!check.accepted()) {
    deadLetterQueue.add(event, check.reason());
    return ProcessingResult.rejected(event.eventId(), check.reason());
}
projection.apply(event);
idempotencyStore.markProcessed(event.eventId());
return ProcessingResult.processed(event.eventId());
}

Das ist kein vollständiges Produktions-Framework, aber es zeigt die richtige Denkweise: Validierung vor Wirkung, Idempotenz vor erneuter Wirkung, Dead Letter statt stiller Datenkorruption.

16. Anti-Patterns im Messaging

Typische Fehler in Enterprise-Projekten:

  • Events als Datenbank-Row-Dump veröffentlichen
  • OrderChanged als einziges Event nutzen
  • Partition Key zufällig wählen
  • Offsets vor fachlicher Wirkung committen
  • Consumer nicht idempotent bauen
  • Schema-Versionen ignorieren
  • DLQ als Papierkorb ohne Prozess verwenden
  • Eventnamen nach technischen Komponenten benennen
  • REST-Denkweise eins zu eins auf Kafka übertragen

Schlechtes Beispiel:

JAVA
public record DbOrderRowChanged(Map<String, Object> row) {
}

Besser:

JAVA
public record OrderPlaced(String orderId, String customerId, long amountInCent, String currency) {
}

Der Unterschied ist nicht syntaktisch, sondern fachlich: Das gute Event beschreibt eine Tatsache im Geschäftsprozess.

17. Tests im Messaging-Design

Messaging muss mit Szenarien getestet werden, nicht nur mit einzelnen Methoden. Das Lab enthält Tests für Consumer-Groups, Idempotenz und Schema-Abweisung.

Wichtige Testfälle:

TEXT
1. Billing und Reporting lesen denselben Eventstrom unabhängig.
2. Duplikat mit gleicher eventId erzeugt keine zweite Projektion.
3. Unbekannte Schema-Version landet in DLQ.
4. Partition Key hält Events einer Order zusammen.
5. Consumer committen erst nach Verarbeitung.

Diese Tests sind Architekturtests im Kleinen. Sie prüfen nicht nur Java-Methoden, sondern die Annahmen des Integrationsdesigns.

18. Produktionscheckliste für Kapitel

Vor einem produktiven Messaging-Design sollte diese Liste beantwortet sein:

  • Welche Events sind fachliche Tatsachen?
  • Welche Commands haben klare Empfänger?
  • Welche Topics existieren und warum?
  • Welcher Partition Key garantiert welche Reihenfolge?
  • Welche Consumer Groups gibt es?
  • Welche Schema-Versionen werden akzeptiert?
  • Wo wird Idempotenz gespeichert?
  • Wann wird Offset committed?
  • Was passiert bei unbekannter Version?
  • Wie werden traceId und correlationId gesetzt?
  • Wie kann ein Consumer neu aufbauen?
  • Wie lange bleiben Events im Broker oder Archiv verfügbar?

Wenn diese Fragen offen sind, ist das System nicht tief verstanden, auch wenn der Code bereits Nachrichten sendet.

19. Durchgehender Order-to-Invoice Messaging-Flow

Der Order-to-Invoice-Flow ist ein gutes Beispiel, weil mehrere fachliche Verantwortungen beteiligt sind. Der Order-Service entscheidet, ob eine Bestellung angenommen wird. Inventory reserviert Bestand. Payment autorisiert Zahlung. Billing erzeugt eine Rechnung. Reporting und Audit hören mit, ohne den Kernfluss zu blockieren.

Ein schlechter Fluss wäre ein einziger synchroner Ablauf:

JAVA
orderRepository.save(order);
inventoryClient.reserve(order);
paymentClient.authorize(order);
billingClient.generateInvoice(order);
reportingClient.update(order);

Dieser Code ist einfach zu lesen, aber operativ gefährlich. Jeder Downstream-Ausfall blockiert die Bestellung. Timeouts werden zu fachlichen Abbrüchen. Wiederholungen können Payment oder Billing doppelt ausführen. Außerdem ist nicht klar, welche Teilschritte schon dauerhaft passiert sind.

Eine bessere Choreografie trennt lokale Entscheidung und nachgelagerte Reaktionen:

TEXT
OrderApplicationService
  -> Order speichern
  -> Outbox(OrderPlaced) schreiben
  -> Commit

OutboxPublisher
  -> order-events publizieren

InventoryConsumer
  -> Bestand reservieren
  -> InventoryReserved publizieren

BillingConsumer
  -> nach PaymentAuthorized Rechnung erzeugen

Deep-Dive-Punkt: Choreografie bedeutet nicht, dass niemand verantwortlich ist. Der fachliche Prozess braucht weiterhin Monitoring, Prozesszustände und Eskalationsregeln.

20. Topic-Design-Alternativen im Vergleich

Eine Enterprise-Plattform kann Topics nach Domäne, Eventtyp, Sicherheitszone oder Betriebsprofil schneiden. Das ist keine reine Geschmacksfrage.

Entscheidung Vorteil Risiko Geeignet wenn
order-events einfache fachliche Landkarte viele Eventtypen Order-Kontext hat moderate Eventmenge
payment-events Sicherheit/Compliance getrennt mehr Topic-Verwaltung Payment braucht eigene Retention/ACL
order-placed Consumer können gezielt abonnieren Topic-Wildwuchs sehr hohe Last pro Eventtyp
audit-events Audit getrennt betreibbar Doppelpublikation möglich Audit ist eigener Bounded Context

Ein guter Architekt dokumentiert nicht nur den gewählten Topic-Namen, sondern auch die verworfenen Alternativen. Später versteht man sonst nicht, warum das System so geschnitten wurde.

21. Event-Versionierung mit Migrationsfenster

Ein Event-Schema muss über ein Migrationsfenster betrachtet werden. Publisher und Consumer werden selten exakt gleichzeitig deployt. Deshalb braucht man Phasen:

TEXT
Phase 1: Consumer kann V1 lesen
Phase 2: Consumer kann V1 und V2 lesen
Phase 3: Publisher sendet V2
Phase 4: alte V1-Events laufen aus Retention/Archiv
Phase 5: Consumer-Unterstützung für V1 wird entfernt

Schlecht:

JAVA
// Publisher wechselt sofort von V1 zu V2, Consumer versteht nur V1.
broker.publish(new OrderPlacedV2(orderId, amount, currency, taxRate));

Besser:

JAVA
// Consumer akzeptiert vor Publisher-Umstellung beide Versionen.
registry.register("OrderPlaced", 1, 2);

Produktionshinweis: Die Retention des Brokers ist Teil der Schema-Strategie. Wenn Events 14 Tage im Topic bleiben, muss ein Consumer in dieser Zeit alte Versionen lesen können.

22. Consumer-Group-Skalierung und Rebalancing

Consumer Groups skalieren nicht beliebig. Die maximale parallele Verarbeitung innerhalb einer Group ist durch die Anzahl der Partitionen begrenzt. Zehn Consumer auf drei Partitionen bedeuten, dass sieben Consumer keine Partition aktiv bearbeiten.

Rebalancing ist ein Betriebsereignis. Wenn Consumer starten, sterben oder langsam werden, werden Partitionen neu verteilt. Währenddessen können Pausen entstehen. Consumer-Code muss deshalb damit umgehen, dass Verarbeitung unterbrochen und später fortgesetzt wird.

Wichtige Regel:

TEXT
Mehr Consumer erhöhen nur dann Durchsatz,
wenn genügend Partitionen existieren und die Downstream-Systeme mithalten.

Ein Billing-Consumer kann Kafka schnell lesen, aber die Datenbank oder der Payment-Adapter kann trotzdem der Engpass sein. Dann hilft mehr Kafka-Parallelität nicht, sondern erzeugt Druck auf abhängige Systeme.

23. Offset-Commit-Fehlerfälle

Offset-Commit ist einer der wichtigsten Punkte im Consumer-Design.

Schlecht:

JAVA
record = poll();
commitOffset(record);
// zu früh
projection.apply(record);
// kann danach fehlschlagen

Wenn projection.apply fehlschlägt, ist das Event technisch als gelesen markiert, aber fachlich nicht verarbeitet. Das ist Datenverlust.

Besser:

JAVA
record = poll();
projection.apply(record);
idempotency.markProcessed(record.eventId());
commitOffset(record);

Jetzt kann ein Crash vor dem Commit zu erneuter Lieferung führen. Deshalb muss die Projektion idempotent sein. Das ist der ehrliche Preis von at-least-once.

24. JMS-Queue vs Kafka-Log an einem Beispiel

Bei einer JMS-Queue wird ein Arbeitsauftrag typischerweise von einem Consumer abgeholt und nach Acknowledgement aus Sicht der Queue erledigt. Das passt gut zu Aufgaben wie GenerateInvoiceCommand.

Bei Kafka bleibt das Event im Log, und jede Consumer Group verwaltet ihre eigene Position. Das passt gut zu Tatsachen wie OrderPlaced, weil Billing, Reporting und Audit unabhängig lesen können.

Nicht gut:

TEXT
OrderPlaced als JMS-Arbeitsauftrag behandeln,
obwohl mehrere unabhängige Systeme dieselbe Tatsache brauchen.

Nicht gut:

TEXT
GenerateInvoiceCommand als Kafka-Domänen-Event publizieren,
obwohl es einen klaren Empfänger und eine Ausführungsabsicht hat.

25. Event-Namenskatalog für die Fallstudie

Gute Eventnamen für die Order/Billing/Payment-Domäne:

TEXT
OrderPlaced
OrderAccepted
OrderRejected
InventoryReserved
InventoryReservationFailed
PaymentAuthorized
PaymentDeclined
InvoiceGenerated
InvoiceGenerationFailed
OrderCancelled
RefundRequested
RefundCompleted

Vorsichtige oder schlechte Namen:

TEXT
OrderChanged
OrderUpdated
ProcessOrderEvent
DoBillingEvent
PaymentStatusChanged
DataSynced

Warum? Die schlechten Namen beschreiben entweder zu wenig Fachlichkeit oder eine technische Aktivität statt einer fachlichen Tatsache.

26. Event-Payload: Minimal, aber ausreichend

Ein Event sollte genug enthalten, damit Consumer ihre Aufgabe erfüllen können, aber nicht das komplette interne Aggregat dumpen.

Zu wenig:

JAVA
public record OrderPlaced(String orderId) {
}

Jetzt müssen Consumer eventuell den Order-Service synchron nachladen. Dadurch entsteht neue Kopplung.

Zu viel:

JAVA
public record OrderPlaced(OrderEntityJpaDump dump) {
}

Jetzt wird die interne Datenstruktur zum öffentlichen Vertrag.

Besser:

JAVA
public record OrderPlaced(
String orderId,
String customerId,
long grossAmountInCent,
String currency,
List<OrderLineSnapshot> lines
) {
}

Snapshot bedeutet: fachlich relevante Daten zum Zeitpunkt des Events, nicht Live-Entity.

27. Testkatalog für Messaging-Architektur

Ein echter Messaging-Testkatalog enthält mehr als einen Happy Path.

TEXT
- Event wird einmal publiziert und einmal verarbeitet.
- Dasselbe Event wird zweimal geliefert und nur einmal angewendet.
- Unbekannte Version wird abgelehnt.
- Alter Consumer liest alte Version weiter.
- Neue optionale Felder brechen alte Consumer nicht.
- Partition Key hält Order-Reihenfolge.
- Zwei Consumer Groups lesen unabhängig.
- Consumer-Lag wird erkennbar.
- DLQ-Eintrag enthält Ursache und Trace-ID.

Diese Tests sind nicht nur technische Tests. Sie sichern Architekturentscheidungen.

28. Kapitel-6A-Produktionscheckliste

Vor Produktion müssen mindestens diese Punkte geklärt sein:

TEXT
Event-Katalog dokumentiert
Topic-Katalog dokumentiert
Partition-Key-Strategie dokumentiert
Schema-Versionierungsregel dokumentiert
Consumer-Groups bekannt
Offset-Commit-Regel festgelegt
Idempotenzspeicher definiert
DLQ-Prozess definiert
Trace-ID und Correlation-ID definiert
Retention und Reprocessing geklärt
ACL/Security für Topics geklärt
Monitoring und Alerts geplant

Wenn ein Team nur den Producer-Code zeigen kann, aber diese Liste nicht beantworten kann, ist das Messaging-Design noch nicht deep-dive-reif.

Code-Anhang - Kurzüberblick

Der vollständige Code liegt unter code/messaging-Kapitel-rebuild. Das PDF hält Code bewusst kurz; die HTML- und ZIP-Fassung enthalten den vollständigen Code.

Code-Labs zu diesem Kapitel

09. Retry, DLQ, Reprocessing und Consumer Lag

Kapitelkompass

Fehlerpfade sind Teil des Designs

Retry, DLQ und Reprocessing brauchen Klassifikation, Begrenzung und eine nachvollziehbare Betriebsentscheidung.

FehlerAusgangspunktKlassifizierenEinordnenRetry/DLQAbsichernReprocessErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Transient und permanent trennen
  • Backoff mit Jitter nutzen
  • DLQ-Kontext erhalten
  • Reprocessing absichern

Praxisfall

Eine Nachricht scheitert wegen Timeout; eine andere wegen ungültiger fachlicher Daten.

Entscheidung

Nur transiente Fehler automatisch erneut versuchen; permanente Fehler sichtbar isolieren.

!

Typisches Risiko

Unbegrenzte Retries blockieren Partitionen und verwandeln einen Einzelfehler in Consumer Lag.

Thema: Retry, DLQ, Reprocessing, Consumer-Lag und Fehlerbetrieb im Messaging.
Position: Kapitel gesamt. Messaging-Vertiefung Kapitel.
Ziel: Nicht nur wissen, dass es Retry und DLQ gibt, sondern entscheiden koennen, wann Retry gefaehrlich ist, wann DLQ sinnvoll ist und wie Reprocessing produktionsnah gesteuert wird.

1. Warum Messaging-Fehlerbetrieb ein eigenes Architekturthema ist

In vielen Enterprise-Systemen wird Messaging zu einfach erklaert: Ein Producer publiziert ein Event, ein Consumer liest es und fuehrt eine Aktion aus. Diese Darstellung ist fuer ein erstes Bild hilfreich, aber sie verschweigt den Teil, der im echten Betrieb am meisten Schmerzen verursacht: Fehler passieren nicht sauber, sondern verteilt, wiederholt und oft erst spaeter sichtbar.

Ein REST-Fehler ist fuer den Aufrufer meist unmittelbar sichtbar. Ein Messaging-Fehler kann dagegen im Hintergrund wachsen: Consumer-Lag steigt, ein Retry-Sturm ueberlastet die Datenbank, eine DLQ fuellt sich, ein einzelnes Poison Event blockiert eine Partition, ein Reprocessing erzeugt doppelte Nebenwirkungen oder ein Bug wird durch automatisches Wiederholen verschleiert. Genau deshalb ist Kapitel kein kleiner Zusatz, sondern ein Kernteil produktionsnaher Enterprise-Architektur.

Typisches falsches Verstaendnis

Eine naive Regel lautet oft: “Wenn Verarbeitung fehlschlaegt, versuche es einfach erneut.” Das klingt robust, ist aber gefaehrlich. Retry hilft nur, wenn die Fehlerursache durch Zeit verschwinden kann. Ein Datenbank-Timeout kann sich erholen. Ein ungueltiges Event-Schema wird durch Warten nicht besser. Ein unbekanntes Enum in einer alten Consumer-Version wird durch zehn Retries nicht gueltig. Ein fehlender Vertrag im Fachsystem muss vielleicht manuell korrigiert werden.

Besseres Mentalmodell

Ein Consumer ist nicht nur fachlicher Code. Er ist eine kleine Betriebsmaschine:

  • Er erkennt Duplikate.
  • Er klassifiziert Fehler.
  • Er entscheidet zwischen Retry, DLQ, Parking Lot, Skip oder Stop.
  • Er sammelt Diagnosekontext.
  • Er schuetzt Downstream-Systeme vor Retry-Stuermen.
  • Er ermoeglicht kontrolliertes Reprocessing.

2. Fehlerklassifikation statt blindem Retry

Fehlerklassifikation

Nicht jeder Fehler gehoert in dieselbe Schublade. Eine gute Fehlerklassifikation trennt technische und fachliche Ursachen. Noch wichtiger: Sie trennt heilbare von nicht heilbaren Fehlern.

Fehlerart Beispiel Retry sinnvoll? Typische Aktion
Transient Timeout, kurzzeitiger Broker-/DB-Fehler ja Retry mit Backoff
Fachlich temporaer referenzierter Kunde noch nicht repliziert ja, begrenzt Delayed Retry
Fachlich final Vertrag dauerhaft ungueltig nein DLQ/Parking Lot
Bug NullPointer, Mapping-Fehler nein Alarm, Fix, danach Reprocessing
Poison Message unlesbares Schema, falsche Version nein isolieren, nicht endlos wiederholen
Duplikat Event erneut geliefert nein idempotent ueberspringen

Schlechte Loesung

JAVA
try {
    consumer.handle(event);
}
catch (Exception ex) {
    // Schlecht: jede Ursache wird gleich behandelt.
    broker.publish("retry-topic", event);
}

Diese Loesung sieht simpel aus, fuehrt aber zu drei Problemen. Erstens wird ein Bug wie ein temporaerer Fehler behandelt. Zweitens kann ein Poison Event unendlich oft verarbeitet werden. Drittens fehlen Diagnoseinformationen. Im Betrieb sieht man nur, dass Events immer wieder auftauchen.

Bessere Loesung

JAVA
package com.example.messaging.Kapitel;
public final class DefaultErrorClassifier implements ErrorClassifier {
    @Override public ConsumerResult.Failure classify(RuntimeException exception, EventEnvelope event) {
        String m = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
        if (m.contains("timeout") || m.contains("temporarily")) return ConsumerResult.transientFailure(m);
        if (m.contains("unknown customer")) return ConsumerResult.temporaryDomain(m);
        if (m.contains("invalid schema")) return ConsumerResult.poison(m);
        return ConsumerResult.bug(m);
    }
}

Der DefaultErrorClassifier ist absichtlich klein, zeigt aber die Architekturidee: Klassifikation ist eine eigene Entscheidung. In echten Systemen wuerde diese Klasse nicht nur Text analysieren, sondern Exception-Typen, Error Codes, Status Codes, Domain-Regeln und Downstream-Antworten auswerten.

3. Retry ist ein Werkzeug, keine Garantie

Retry verbessert Robustheit nur, wenn drei Bedingungen erfuellt sind: Der Fehler ist heilbar, die Nebenwirkung ist idempotent, und der Retry belastet das System nicht staerker als der Originalfehler. Viele Systeme scheitern, weil sie nur die erste Bedingung betrachten.

Exponential Backoff

JAVA
package com.example.messaging.Kapitel;
import java.time.Duration;
// Pattern: Strategy - kapselt Retry-Regeln, ohne Consumer-Code zu veraendern.
public final class ExponentialBackoffRetryPolicy implements RetryPolicy {
    private final int maxAttempts;
    private final Duration baseDelay;
    public ExponentialBackoffRetryPolicy(int maxAttempts, Duration baseDelay) {
        this.maxAttempts = maxAttempts;
        this.baseDelay = baseDelay;
    }
@Override public RetryDecision decide(EventEnvelope event, ConsumerResult.Failure failure) {
    if (!failure.retryable()) return RetryDecision.stop("failure is not retryable: " + failure.kind());
    if (event.attempt() >= maxAttempts) return RetryDecision.stop("max attempts reached: " + event.attempt());
    long factor = 1L << Math.min(event.attempt(), 8);
    return RetryDecision.retryAfter(baseDelay.multipliedBy(factor), "retryable " + failure.kind());
}
}

Die Policy begrenzt die Zahl der Versuche und vergroessert die Wartezeit. Dadurch wird aus “nochmal sofort” eine kontrollierte Entlastungsstrategie. Trotzdem loest Backoff nicht alle Probleme. Wenn 50.000 Events alle dieselbe defekte Payload haben, entsteht trotz Backoff ein Strom nutzloser Arbeit. Deshalb braucht Retry immer eine DLQ- oder Parking-Lot-Strategie.

Produktionshinweis

Retry sollte messbar sein. Wichtige Metriken sind Retry-Rate, Retry-Alter, Anzahl faelliger Retries, maximale Attempt-Zahl und Anteil erfolgreich nach Retry. Ohne diese Werte sieht ein System stabil aus, obwohl es im Hintergrund aufgestaute Arbeit erzeugt.

4. DLQ: Diagnose statt Muellhalde

Eine Dead Letter Queue ist nicht einfach ein Papierkorb. Sie ist ein Diagnosekanal. Ein DLQ-Eintrag muss so viel Kontext enthalten, dass man spaeter entscheiden kann, ob Reprocessing sicher ist.

JAVA
package com.example.messaging.Kapitel;
import java.time.Instant;
import java.util.*;
// Pattern: Dead Letter Channel - isoliert nicht verarbeitbare Nachrichten mit Diagnosekontext.
public final class DeadLetterQueue {
    private final List<DeadLetterRecord> records = new ArrayList<>();
    public void add(EventEnvelope event, ConsumerResult.Failure failure, Instant now) {
        records.add(new DeadLetterRecord(event, failure.kind(), failure.message(), now));
    }
public List<DeadLetterRecord> records() {
    return List.copyOf(records);
}
public DeadLetterRecord removeFirst() {
    return records.removeFirst();
}
public int size() {
    return records.size();
}
}

Mindestens gespeichert werden sollten:

  • Event-ID und Event-Typ
  • Aggregate-ID oder fachlicher Key
  • Schema-Version
  • Correlation-ID
  • Fehlerklasse
  • Fehlernachricht
  • Attempt-Zahl
  • Zeitpunkt
  • Consumer-Version

Typischer Fehler

Viele Teams legen nur die Payload in eine DLQ. Beim Incident weiss dann niemand, welche Consumer-Version den Fehler erzeugt hat, ob es schon Retries gab oder ob dasselbe Event bereits teilweise Nebenwirkungen erzeugt hat. Eine solche DLQ ist fuer Betrieb fast wertlos.

5. Parking Lot: wenn automatische Verarbeitung gefaehrlich ist

Das Parking-Lot-Muster trennt nicht automatisch verarbeitbare Events von normalen DLQ-Faellen. Ein Event im Parking Lot braucht meist menschliche oder kontrollierte technische Entscheidung: Stammdaten reparieren, Consumer-Version fixen, Mapping anpassen oder Payload migrieren.

JAVA
package com.example.messaging.Kapitel;
import java.util.*;
// Pattern: Quarantine/Parking Lot - trennt operative Korrektur von automatischen Retries.
public final class ParkingLot {
    private final List<DeadLetterRecord> parked = new ArrayList<>();
    public void park(DeadLetterRecord record) {
        parked.add(record);
    }
public List<DeadLetterRecord> parked() {
    return List.copyOf(parked);
}
public int size() {
    return parked.size();
}
}

Parking Lot ist besonders sinnvoll bei:

  • Poison Messages
  • unbekannten Schema-Versionen
  • Mapping-Bugs
  • fachlichen Datenfehlern, die manuell repariert werden muessen
  • Events mit hohem finanziellen Risiko

6. Idempotenz: die wichtigste Consumer-Eigenschaft

Broker koennen Events erneut liefern. Consumer koennen nach einer Nebenwirkung abstuerzen, bevor der Offset gespeichert wurde. Publisher koennen nach dem Senden abstuerzen, bevor der Versandstatus aktualisiert wurde. Daher ist idempotente Verarbeitung keine Optimierung, sondern Pflicht.

JAVA
package com.example.messaging.Kapitel;
import java.util.*;
// Pattern: Idempotent Receiver - verhindert doppelte Nebenwirkungen bei erneuter Zustellung.
public final class IdempotencyStore {
    private final Set<String> processed = new HashSet<>();
    public boolean alreadyProcessed(String eventId) {
        return processed.contains(eventId);
    }
public void markProcessed(String eventId) {
    processed.add(eventId);
}
public int size() {
    return processed.size();
}
}

Eine einfache Event-ID reicht nur, wenn jede fachliche Nebenwirkung genau an diese ID gebunden ist. In echten Systemen ist oft ein fachlicher Idempotency-Key besser, zum Beispiel invoice:{orderId}:{eventVersion}. So verhindert man doppelte Rechnungen, auch wenn dasselbe fachliche Ereignis mit neuer technischer Event-ID erneut kommt.

7. Reliable Consumer Executor als Betriebs-Fassade

Retry und Reprocessing Flow

Der Consumer selbst sollte fachlich bleiben. Er sollte nicht voll mit Retry, DLQ, Metrics und Parking-Lot-Logik sein. Deshalb kapselt der ReliableConsumerExecutor die Betriebslogik.

JAVA
package com.example.messaging.Kapitel;
import java.time.Instant;
// Pattern: Facade - buendelt Retry, DLQ, Idempotenz und Metrics um einen fachlichen Consumer.
public final class ReliableConsumerExecutor {
    private final Consumer consumer;
    private final IdempotencyStore idempotency;
    private final RetryPolicy retryPolicy;
    private final RetryScheduler retryScheduler;
    private final DeadLetterQueue dlq;
    private final ParkingLot parkingLot;
    private final ErrorClassifier classifier;
    private final ProcessingMetrics metrics;
    public ReliableConsumerExecutor(Consumer consumer, IdempotencyStore idempotency, RetryPolicy retryPolicy,
    RetryScheduler retryScheduler, DeadLetterQueue dlq, ParkingLot parkingLot,
    ErrorClassifier classifier, ProcessingMetrics metrics) {
        this.consumer = consumer;
        this.idempotency = idempotency;
        this.retryPolicy = retryPolicy;
        this.retryScheduler = retryScheduler;
        this.dlq = dlq;
        this.parkingLot = parkingLot;
        this.classifier = classifier;
        this.metrics = metrics;
    }
public ConsumerResult process(EventEnvelope event, Instant now) {
    if (idempotency.alreadyProcessed(event.eventId())) {
        metrics.duplicate();
        return ConsumerResult.duplicate(event.eventId());
    }
ConsumerResult result;
try {
    result = consumer.handle(event);
}
catch (RuntimeException ex) {
    result = classifier.classify(ex, event);
}
if (result instanceof ConsumerResult.Success) {
    idempotency.markProcessed(event.eventId());
    metrics.success();
    return result;
}
if (result instanceof ConsumerResult.Failure failure) {
    RetryDecision decision = retryPolicy.decide(event, failure);
    if (decision.retry()) {
        retryScheduler.schedule(event, decision.delay(), decision.reason(), now);
        metrics.retryScheduled();
        return failure;
    }
dlq.add(event, failure, now);
metrics.dlq();
if (failure.kind() == FailureKind.POISON_MESSAGE || failure.kind() == FailureKind.BUG) {
    parkingLot.park(dlq.records().getLast());
    metrics.parked();
}
return failure;
}
return result;
}
}

Diese Fassade macht die wichtigsten Regeln sichtbar:

  1. Duplikate werden vor der Nebenwirkung erkannt.
  2. Erfolgreiche Verarbeitung markiert das Event als verarbeitet.
  3. Retryable Failures werden geplant, nicht sofort blind wiederholt.
  4. Nicht retrybare Fehler gehen in die DLQ.
  5. Bug und Poison Message landen zusaetzlich im Parking Lot.
  6. Metriken werden an einer Stelle gefuehrt.

8. Consumer-Lag: Betriebskennzahl mit Fachbezug

Consumer Lag

Consumer-Lag ist nicht nur eine technische Zahl. Lag bedeutet: Es gibt fachliche Arbeit, die noch nicht verarbeitet wurde. Wenn der Billing-Consumer 200.000 Events hinterherhinkt, fehlen vielleicht Rechnungen, Reporting-Zahlen sind veraltet oder Kunden bekommen keine Benachrichtigung.

JAVA
package com.example.messaging.Kapitel;
import java.util.*;
public final class ConsumerLagMonitor {
    public List<LagSnapshot> measure(InMemoryTopic topic, ConsumerGroupOffsets offsets) {
        List<LagSnapshot> out = new ArrayList<>();
        for (int p=0; p<topic.partitions(); p++) {
            long end = topic.endOffset(p);
            long committed = offsets.committedOffset(topic.name(), p);
            out.add(new LagSnapshot(topic.name(), p, end, committed, Math.max(0, end - committed)));
        }
    return out;
}
public long totalLag(InMemoryTopic topic, ConsumerGroupOffsets offsets) {
    return measure(topic, offsets).stream().mapToLong(LagSnapshot::lag).sum();
}
}

Lag richtig interpretieren

Ein hoher Lag kann verschiedene Ursachen haben:

  • Consumer ist zu langsam.
  • Downstream-Datenbank ist langsam.
  • Partition-Key erzeugt Hotspot.
  • Consumer-Gruppe rebalance-t staendig.
  • Poison Message erzeugt Fehlerloop.
  • Retry-Sturm blockiert normale Verarbeitung.
  • Payload ist zu gross.

Ein Team sollte daher nicht nur “mehr Consumer starten”. Wenn ein einzelner Key eine Partition dominiert, hilft horizontales Skalieren nur begrenzt. Wenn die Datenbank der Flaschenhals ist, macht mehr Parallelitaet die Lage schlechter.

9. Reprocessing ist ein kontrollierter Betriebsprozess

Reprocessing Governance

Reprocessing ist gefaehrlich, wenn es als simpler Replay-Button verstanden wird. Ein Replay kann doppelte Rechnungen erzeugen, externe APIs erneut aufrufen oder alte Events gegen neue Regeln ausfuehren. Daher braucht Reprocessing Governance.

JAVA
package com.example.messaging.Kapitel;
import java.time.Instant;
import java.util.*;
// Pattern: Process Manager - steuert manuelles Reprocessing mit Limit und Audit-Hinweis.
public final class ReprocessingService {
    private final InMemoryTopic topic;
    public ReprocessingService(InMemoryTopic topic) {
        this.topic = topic;
    }
public List<MessageRecord> replayFromDlq(DeadLetterQueue dlq, int maxRecords, String operator) {
    List<MessageRecord> requeued = new ArrayList<>();
    int count = Math.min(maxRecords, dlq.size());
    for (int i=0; i<count; i++) {
        DeadLetterRecord record = dlq.removeFirst();
        EventEnvelope e = record.event();
        EventEnvelope replay = new EventEnvelope(e.eventId(), e.eventType(), e.aggregateId(), e.schemaVersion(), e.occurredAt(),
        new java.util.LinkedHashMap<>(e.headers()) {
            {
                put("reprocessedBy", operator); put("reprocessedAt", Instant.now().toString());
            }
    },
e.payload(), 0);
requeued.add(topic.append(replay.aggregateId(), replay));
}
return requeued;
}
}

Der Code zeigt bewusst ein Limit. Reprocessing sollte nicht standardmaessig “alles aus DLQ zurueck” bedeuten. Besser ist ein kleines Fenster, Beobachtung, dann weiteres Fenster.

Reprocessing-Checkliste

  • Ist die Ursache behoben?
  • Sind Consumer idempotent?
  • Sind Nebenwirkungen sicher oder kompensierbar?
  • Gibt es ein Limit pro Lauf?
  • Wird der Operator protokolliert?
  • Wird Lag waehrend Reprocessing beobachtet?
  • Gibt es eine Abbruchregel?

10. Polling, Offset-Commit und Fehlerstrategie

JAVA
package com.example.messaging.Kapitel;
import java.time.Instant;
import java.util.*;
public final class BatchPoller {
    private final InMemoryTopic topic;
    private final ConsumerGroupOffsets offsets;
    private final ReliableConsumerExecutor executor;
    public BatchPoller(InMemoryTopic topic, ConsumerGroupOffsets offsets, ReliableConsumerExecutor executor) {
        this.topic = topic;
        this.offsets = offsets;
        this.executor = executor;
    }
public int pollOnce(int maxPerPartition, Instant now) {
    int processed = 0;
    for (int p=0; p<topic.partitions(); p++) {
        long start = offsets.committedOffset(topic.name(), p);
        List<MessageRecord> records = topic.readFrom(p, start, maxPerPartition);
        for (MessageRecord record : records) {
            executor.process(record.event(), now);
            offsets.commit(record);
            // In echter Infrastruktur haengt Commit von Fehlerstrategie ab.
            processed++;
        }
}
return processed;
}
}

Der BatchPoller committet in diesem Lab nach der Verarbeitung, um das Modell einfach zu halten. In echten Kafka-Systemen ist diese Entscheidung sehr wichtig. Commit vor Nebenwirkung kann Daten verlieren. Commit nach Nebenwirkung kann Duplikate erzeugen, wenn der Prozess danach abstuerzt. Deshalb lautet die praktische Regel: Duplikate akzeptieren und Nebenwirkungen idempotent machen ist meist robuster als zu versuchen, Duplikate komplett zu vermeiden.

11. Testszenarien im Lab

JAVA
package com.example.messaging.Kapitel;
import java.time.*;
public final class MessagingKapitel {
    public static void main(String[] args) {
        retriesTransientFailureAndThenSucceeds();
        sendsFinalDomainFailureToDlq();
        skipsDuplicateEvent();
        parksPoisonMessage();
        measuresConsumerLag();
        reprocessesFromDlqWithLimit();
        System.out.println("Kapitel");
    }
static Env env() {
    return new Env();
}
static void retriesTransientFailureAndThenSucceeds() {
    Env e = env();
    e.consumer.failTransientTimes("o-1", 1);
    EventEnvelope event = EventEnvelope.newOrderEvent("o-1", "{}");
    e.executor.process(event, Instant.EPOCH);
    assertEquals(1, e.retry.size(), "retry scheduled");
    EventEnvelope due = e.retry.due(Instant.EPOCH.plusSeconds(100)).getFirst();
    e.executor.process(due, Instant.EPOCH.plusSeconds(100));
    assertEquals(1, e.metrics.successCount(), "success after retry");
    assertEquals(0, e.dlq.size(), "no dlq");
}
static void sendsFinalDomainFailureToDlq() {
    Env e = env();
    e.consumer.failFinalDomain("o-2");
    e.executor.process(EventEnvelope.newOrderEvent("o-2", "{}"), Instant.EPOCH);
    assertEquals(1, e.dlq.size(), "final failure dlq");
}
static void skipsDuplicateEvent() {
    Env e = env();
    EventEnvelope event = EventEnvelope.newOrderEvent("o-3", "{}");
    e.executor.process(event, Instant.EPOCH);
    e.executor.process(event, Instant.EPOCH);
    assertEquals(1, e.metrics.successCount(), "one side effect");
    assertEquals(1, e.metrics.duplicateCount(), "duplicate skipped");
}
static void parksPoisonMessage() {
    Env e = env();
    e.consumer.poison("o-4");
    e.executor.process(EventEnvelope.newOrderEvent("o-4", "bad"), Instant.EPOCH);
    assertEquals(1, e.dlq.size(), "poison dlq");
    assertEquals(1, e.parking.size(), "poison parked");
}
static void measuresConsumerLag() {
    InMemoryTopic topic = new InMemoryTopic("orders", 2);
    ConsumerGroupOffsets offsets = new ConsumerGroupOffsets();
    topic.append("a", EventEnvelope.newOrderEvent("a", "{}"));
    topic.append("b", EventEnvelope.newOrderEvent("b", "{}"));
    long lag = new ConsumerLagMonitor().totalLag(topic, offsets);
    if (lag < 2) throw new AssertionError("lag must be at least 2 but was " + lag);
}
static void reprocessesFromDlqWithLimit() {
    InMemoryTopic topic = new InMemoryTopic("orders", 2);
    DeadLetterQueue dlq = new DeadLetterQueue();
    dlq.add(EventEnvelope.newOrderEvent("x", "{}"), ConsumerResult.finalDomain("broken"), Instant.EPOCH);
    dlq.add(EventEnvelope.newOrderEvent("y", "{}"), ConsumerResult.finalDomain("broken"), Instant.EPOCH);
    ReprocessingService service = new ReprocessingService(topic);
    assertEquals(1, service.replayFromDlq(dlq, 1, "ops").size(), "one replayed");
    assertEquals(1, dlq.size(), "one remains");
}
static void assertEquals(Object expected, Object actual, String label) {
    if (!expected.equals(actual)) throw new AssertionError(label + " expected=" + expected + " actual=" + actual);
}
static final class Env {
    final FlakyBillingProjectionConsumer consumer = new FlakyBillingProjectionConsumer();
    final RetryScheduler retry = new RetryScheduler();
    final DeadLetterQueue dlq = new DeadLetterQueue();
    final ParkingLot parking = new ParkingLot();
    final ProcessingMetrics metrics = new ProcessingMetrics();
    final ReliableConsumerExecutor executor = new ReliableConsumerExecutor(consumer, new IdempotencyStore(),
    new ExponentialBackoffRetryPolicy(2, Duration.ofSeconds(5)), retry, dlq, parking,
    new DefaultErrorClassifier(), metrics);
}
}

Die Tests pruefen nicht nur triviale Happy Paths. Sie pruefen die Fehlerwege, die im Betrieb relevant werden:

  • transienter Fehler erzeugt Retry und wird spaeter erfolgreich verarbeitet
  • finale fachliche Fehler gehen in DLQ
  • Duplikate werden uebersprungen
  • Poison Messages werden geparkt
  • Consumer-Lag wird sichtbar
  • DLQ-Reprocessing ist limitiert

12. Runbook fuer Incident-Situationen

Runbook Entscheidungsbaum

Ein Messaging-Incident darf nicht mit blinden Aktionen beantwortet werden. Die falsche Reaktion auf hohen Lag ist oft “mehr Instanzen”. Die falsche Reaktion auf DLQ ist oft “alles erneut senden”. Beides kann den Schaden vergroessern.

JAVA
package com.example.messaging.Kapitel;
public final class RunbookAdvisor {
    public String advise(long lag, int dlqSize, int retrySize) {
        if (dlqSize > 0 && retrySize > 0) return "DLQ und Retry gleichzeitig: Fehlerklassifikation pruefen, nicht blind skalieren.";
        if (lag > 1000) return "Lag hoch: Downstream-Latenz, Partition-Hotspot und Consumer-Rebalancing pruefen.";
        if (dlqSize > 0) return "DLQ vorhanden: Samples lesen, Fehlerklasse bestimmen, Reprocessing erst nach Fix.";
        if (retrySize > 100) return "Retry-Sturm: Backoff vergroessern, Rate limitieren, Circuit Breaker pruefen.";
        return "System stabil: SLO, Lag und DLQ weiter beobachten.";
    }
}

Typische Produktionsfehler

Symptom Falsche Reaktion Bessere Reaktion
Lag steigt mehr Consumer starten Ursache finden: Downstream, Hotspot, Fehlerloop
DLQ waechst alles replayen Samples klassifizieren, Ursache fixen
Retry-Rate steigt Retry aggressiver machen Backoff vergroessern, Downstream schuetzen
Poison Message endlos retryen isolieren, Schema/Mapping fixen
Reprocessing erzeugt Fehler nochmal replayen stoppen, Nebenwirkungen pruefen

13. Code-Walkthrough: Demo

JAVA
package com.example.messaging.Kapitel;
import java.time.*;
public final class MessagingKapitel {
    public static void main(String[] args) {
        InMemoryTopic topic = new InMemoryTopic("order-events", 3);
        ConsumerGroupOffsets offsets = new ConsumerGroupOffsets();
        FlakyBillingProjectionConsumer consumer = new FlakyBillingProjectionConsumer();
        consumer.failTransientTimes("order-200", 1);
        consumer.failFinalDomain("order-404");
        consumer.poison("order-666");
        RetryScheduler retry = new RetryScheduler();
        DeadLetterQueue dlq = new DeadLetterQueue();
        ParkingLot parking = new ParkingLot();
        ProcessingMetrics metrics = new ProcessingMetrics();
        ReliableConsumerExecutor executor = new ReliableConsumerExecutor(consumer, new IdempotencyStore(),
        new ExponentialBackoffRetryPolicy(2, Duration.ofSeconds(10)), retry, dlq, parking,
        new DefaultErrorClassifier(), metrics);
        topic.append("order-100", EventEnvelope.newOrderEvent("order-100", "{total:42}"));
        topic.append("order-200", EventEnvelope.newOrderEvent("order-200", "{total:99}"));
        topic.append("order-404", EventEnvelope.newOrderEvent("order-404", "{total:11}"));
        topic.append("order-666", EventEnvelope.newOrderEvent("order-666", "{total:13}"));
        BatchPoller poller = new BatchPoller(topic, offsets, executor);
        poller.pollOnce(10, Instant.parse("2026-01-01T10:00:00Z"));
        for (EventEnvelope due : retry.due(Instant.parse("2026-01-01T10:01:00Z"))) executor.process(due, Instant.parse("2026-01-01T10:01:00Z"));
        long lag = new ConsumerLagMonitor().totalLag(topic, offsets);
        System.out.println("Kapitel metrics=" + metrics + " lag=" + lag + " dlq=" + dlq.size() + " parking=" + parking.size());
    }
}

Die Demo erzeugt vier Events: ein erfolgreiches Event, ein transient fehlschlagendes Event, ein final fachlich fehlschlagendes Event und ein Poison Event. Dadurch sieht man, warum ein Consumer nicht nur try/catch sein darf. Dasselbe technische Muster entscheidet je nach Fehlerklasse anders.

14. Architekturentscheidungen

  1. Retry ist begrenzt. Unbegrenzter Retry versteckt Fehler und erzeugt Last.
  2. DLQ enthaelt Diagnosekontext. Payload allein reicht nicht.
  3. Parking Lot isoliert gefaehrliche Events. Nicht alles darf automatisch wieder in den normalen Fluss.
  4. Idempotenz ist Pflicht. Duplikate sind kein Sonderfall, sondern Normalfall verteilter Systeme.
  5. Reprocessing ist ein Betriebsprozess. Es braucht Limit, Operator, Audit und Beobachtung.

15. Deep-Dive Lernaufgaben

  1. Erweitere DefaultErrorClassifier, sodass HTTP-Statuscodes getrennt klassifiziert werden.
  2. Erweitere RetryPolicy, sodass bestimmte Aggregate-IDs langsamer wiederholt werden.
  3. Baue einen Report, der DLQ-Eintraege nach Fehlerart gruppiert.
  4. Erweitere ReprocessingService, sodass nur Events eines Typs reprocessed werden.
  5. Fuege eine Metrik fuer “oldest retry age” hinzu.
  6. Diskutiere, wann man bei Poison Messages den Consumer komplett stoppen sollte.

16. Zusammenfassung

Kapitel zeigt, dass Messaging-Betrieb eine Architekturdisziplin ist. Retry, DLQ und Reprocessing sind nicht nur technische Features eines Brokers, sondern Entscheidungen ueber Konsistenz, Risiko, Last und fachliche Korrektheit. Ein produktionsnahes Enterprise-System braucht deshalb nicht nur Topics und Consumer, sondern Fehlerklassifikation, Idempotenz, Metriken, Runbooks und kontrolliertes Reprocessing.

Code-Labs zu diesem Kapitel

10. Messaging-Betrieb, Monitoring und Backpressure

Kapitelkompass

Backpressure schützt das Gesamtsystem

Produktionsbetrieb verbindet Lag, Durchsatz, Downstream-Kapazität und kontrollierte Lastaufnahme.

LagAusgangspunktKapazitätEinordnenDrosselnAbsichernErholenErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Lag richtig interpretieren
  • Kapazität korrelieren
  • Backpressure anwenden
  • Runbooks auslösen

Praxisfall

Der Consumer skaliert hoch, aber Datenbankpool und Fremd-API bleiben unverändert begrenzt.

Entscheidung

Concurrency am langsamsten Downstream ausrichten und mit Bulkhead sowie Rate Limit schützen.

!

Typisches Risiko

Mehr Consumer erhöhen ohne Downstream-Budget oft nur Timeouts, Retries und Lag.

Schnelle Einordnung
SignalLesartNächster Schritt
Lag steigt, CPU freiDownstream oder Partition blockiertPool, Remote-Latenz und Head-of-line Blocking prüfen
Lag steigt, CPU vollVerarbeitung CPU-limitiertProfilen, Partitionen und Instanzbudget gemeinsam bewerten
Retry-Rate steigtFehler verstärkt LastRetry-Budget senken, Circuit Breaker oder Pause einsetzen

Messaging-Betrieb, Monitoring, Lag-SLO, Backpressure, Alerting und Runbook Deep Dive.

1. Warum Messaging-Betrieb ein eigenes Architekturthema ist

Messaging wirkt in Entwicklungsumgebungen oft harmlos: Producer schreibt ein Event, Consumer verarbeitet es, Test grün. In Produktion ist Messaging aber ein laufendes System mit Warteschlangen, Offsets, Rebalancing, Downstream-Abhängigkeiten, Backpressure und Betriebsentscheidungen. Ein Consumer kann fachlich korrekt sein und trotzdem das System zerstören, wenn er bei einem Downstream-Ausfall aggressiv retryt.

Der wichtigste Perspektivwechsel lautet: Ein Topic ist keine Methode. Ein Topic ist ein zeitlich entkoppelter Strom. Sobald Zeit ins Spiel kommt, entstehen neue Fragen: Wie alt darf ein Event werden? Wie groß darf Lag sein? Welche Partition ist kritisch? Wann ist ein Retry noch Heilung und wann ist er Lastverstärkung? Wer darf reprocessen? Wie wird nachgewiesen, dass nach einem Incident nichts verloren ging?

Die schlechte Lösung ist ein Consumer, der Exceptions loggt und immer wieder versucht. Das sieht robust aus, erzeugt aber oft einen Retry-Sturm. Die bessere Lösung trennt Verarbeitung, Fehlerklassifikation, Backpressure, DLQ, Runbook und Metriken. Genau diese Trennung wird in diesem Kapitel fachlich und im Code aufgebaut.

2. SLI und SLO für Consumer-Lag

Lag allein ist keine ausreichende Kennzahl. Ein Lag von 10.000 Events kann harmlos sein, wenn Events klein sind und ein Consumer 5.000 Events pro Sekunde schafft. Ein Lag von 100 Events kann kritisch sein, wenn jedes Event eine Rechnung erzeugt und der Consumer wegen eines Downstream-Problems seit 20 Minuten festhängt. Darum ist Max Event Age oft wichtiger als reine Offset-Differenz.

Ein SLI ist eine Messgröße: Consumer Lag, Processing Latency, Max Event Age, DLQ Growth, Retry Rate, Throughput. Ein SLO ist ein Ziel: 99 Prozent der OrderPlaced-Events müssen innerhalb von 5 Minuten im Billing sichtbar sein. Der Unterschied ist entscheidend: Ein SLI beschreibt Technik, ein SLO beschreibt erwartetes Verhalten für Nutzer, Fachbereich oder Folgeprozess.

Naiv wird oft alertiert, sobald Lag größer als ein fixer Wert ist. Das führt zu Fehlalarmen. Besser ist eine Kette: Warnung bei steigendem Trend, kritischer Alarm bei Event Age oder DLQ-Wachstum, Incident bei SLO-Verletzung. Alerts müssen eine Handlung ermöglichen. Ein Alarm ohne Runbook ist nur Lärm.

SLI SLO Alert Chain

3. Dashboard richtig lesen

Ein gutes Dashboard beantwortet nicht nur “was ist rot?”, sondern “was ist wahrscheinlich die Ursache?”. Dafür werden Kennzahlen kombiniert. Lag hoch und Throughput hoch kann bedeuten, dass der Producer einfach mehr Last liefert. Lag hoch und Throughput niedrig weist auf Consumer- oder Downstream-Probleme hin. DLQ-Wachstum zeigt meist Daten- oder Codeprobleme. Retry-Rate plus Downstream-Latenz zeigt drohenden Retry-Sturm.

Im Enterprise-Umfeld sollte jedes wichtige Topic mindestens vier Blickwinkel haben: Stromzustand, Consumer-Zustand, Fehlerzustand und Business-Zustand. Stromzustand heißt: Throughput, Partition-Verteilung, Event-Alter. Consumer-Zustand heißt: Instanzen, Rebalancing, Verarbeitungslatenz. Fehlerzustand heißt: Retry, DLQ, Poison Messages. Business-Zustand heißt: Rechnungen erzeugt, Zahlungen verbucht, Reporting aktuell.

Ein Dashboard ist kein Selbstzweck. Es ist der Einstieg ins Runbook. Wer ein Diagramm sieht, muss wissen, welche Entscheidung danach folgt: beobachten, skalieren, bremsen, Consumer stoppen, DLQ analysieren, Replay vorbereiten oder Incident eröffnen.

Operations Dashboard

4. Backpressure als Schutzmechanismus

Backpressure bedeutet: Das System erkennt, dass ein nachgelagertes System überlastet ist, und reduziert absichtlich die eigene Geschwindigkeit. Das fühlt sich zunächst falsch an, weil Lag dadurch steigen kann. In Wahrheit verhindert Backpressure aber, dass ein lokales Problem zu einem systemweiten Ausfall wird.

Eine schlechte Lösung ist unlimitierter Parallelismus: Für jedes Event sofort ein HTTP-Call, bei Fehler sofort Retry, bei Timeout noch mehr Threads. Das erzeugt mehr Last genau dort, wo das Zielsystem schon Probleme hat. Eine bessere Lösung nutzt Rate Limits, begrenzte In-Flight-Events, Pause/Resume-Mechanismen, exponentielles Backoff und getrennte Reprocessing-Fenster.

Wichtig ist die Offset-Strategie. Wenn ein Consumer vor der Nebenwirkung committet, kann Arbeit verloren gehen. Wenn er nach der Nebenwirkung nicht idempotent ist, können Duplikate Schaden erzeugen. Backpressure muss daher immer zusammen mit Idempotenz, Offset-Commit und DLQ-Strategie gedacht werden.

Backpressure Flow

5. Hot Partitions und falsche Partition Keys

Viele Teams skalieren Consumer, wundern sich aber, dass Lag nicht sinkt. Die Ursache ist oft eine Hot Partition. Kafka skaliert nicht über Consumer-Instanzen beliebig, sondern über Partitionen. Wenn ein einzelner Key sehr viele Events erzeugt, landen sie auf derselben Partition und können nur begrenzt parallel verarbeitet werden.

Typische Ursachen sind MandantId als Key bei einem sehr großen Mandanten, CustomerId bei Großkunden, ein konstanter Key, oder ein fachlich zu großes Aggregate. Die schnelle Lösung ist selten einfach “mehr Consumer”. Mehr Consumer als aktive Partitionen bringen nichts. Bei Hot Partitions muss man die Key-Strategie, Event-Größe und Aggregatgrenzen neu bewerten.

Kurzfristig hilft Diagnose: Lag pro Partition, Events pro Key, Alter pro Partition. Mittelfristig helfen Sub-Sharding, gesonderte Topics für Großkunden, Prioritätsströme oder fachliche Aufteilung. Langfristig braucht es eine Architekturentscheidung, die Ordering-Anforderungen und Parallelisierung bewusst gegeneinander abwägt.

Hot Partition Diagnose

6. Graceful Shutdown und Deployment-Fenster

Messaging-Deployments scheitern oft beim Stoppen. Eine Instanz wird beendet, während Events in Verarbeitung sind. Danach übernimmt eine andere Instanz dieselbe Partition. Ohne Idempotenz entstehen Duplikate. Mit falschem Offset-Commit gehen Events verloren. Mit zu kurzer Shutdown-Zeit bleiben Transaktionen halb fertig.

Ein sauberer Shutdown hat Schritte: Readiness auf false setzen, keine neuen Partitionen annehmen, laufende Verarbeitung abschließen, Offsets nur für abgeschlossene Nebenwirkungen committen, Rebalance erlauben, danach Metriken prüfen. Das klingt organisatorisch, ist aber technische Architektur.

Deployment-Readiness ist ebenfalls wichtig. Vor einem Deployment sollte geprüft werden, ob Lag bereits hoch ist, DLQ wächst, Schema kompatibel ist und genug Consumer-Kapazität vorhanden ist. Ein Deployment in einen bereits instabilen Strom hinein verschleiert Ursachen und erschwert Rollback.

Graceful Shutdown

7. Runbook-Entscheidungen als Code modellieren

Ein Runbook sollte nicht nur als Wiki-Seite existieren. Die wichtigsten Entscheidungsregeln sollten als Code testbar sein. Dadurch wird sichtbar, wann Backpressure ausgelöst wird, wann eine Hot Partition untersucht wird und wann ein Consumer gestoppt werden muss. Der Code in diesem Kapitel zeigt das mit RunbookDecisionEngine und BackpressureController.

Der Vorteil ist nicht, dass ein Mensch ersetzt wird. Der Vorteil ist, dass Betriebswissen explizit wird. Wenn ein neuer Entwickler wissen will, warum bei DLQ-Wachstum gestoppt wird, findet er die Regel im Code, den Test und die Dokumentation. Wenn ein SRE die Schwelle anpasst, kann ein Test zeigen, welche Szenarien betroffen sind.

Das ist auch ein Design-Pattern-Thema: Policy Object, Decision Table und Monitor/Observer trennen Messung, Bewertung und Aktion. Diese Trennung verhindert, dass Consumer-Code zu einer Mischung aus Fachlogik, Betrieb, Retry, Logging und Incident-Entscheidung wird.

8. Code-Walkthrough: LagMonitor, BackpressureController, RunbookDecisionEngine

Der LagMonitor liest ein ConsumerGroupSnapshot und erzeugt Alerts. Er kennt die Fachlogik des Events nicht. Er kennt nur Betriebsregeln: Max Lag, Max Event Age und Max Processing Latency. Dadurch bleibt er unabhängig vom konkreten Consumer.

Der BackpressureController entscheidet, ob normal verarbeitet, begrenzt oder pausiert wird. Er kombiniert Downstream-Fehlerquote und Latenz. In echten Systemen würden zusätzliche Signale dazukommen: Connection-Pool-Auslastung, HTTP-429-Antworten, CPU-Saturation und Circuit-Breaker-Zustand.

Die RunbookDecisionEngine kombiniert Alerts, DLQ-Wachstum, Hot Partition und Downstream-Fehler. Daraus entstehen konkrete Aktionen: OBSERVE, SCALE_CONSUMERS, APPLY_BACKPRESSURE, INVESTIGATE_HOT_PARTITION, PAUSE_AND_FIX oder CONTROLLED_REPLAY.

JAVA
var snapshot = OperationsSnapshotFactory.downstreamSlow();
var monitor = new LagMonitor(new LagSlo(30_000, 300, 1_000));
var alerts = monitor.evaluate(snapshot);
var backpressure = new BackpressureController().decide(snapshot, 55);
var decision = new RunbookDecisionEngine().decide(snapshot, alerts, 3, 55);

9. Tests als Betriebsvertrag

Kapitel enthält Tests, die keine technischen Kleinigkeiten prüfen, sondern Betriebsverträge: Bei verletztem Event-Age-SLO entsteht ein kritischer Alert. Bei einer Hot Partition entsteht eine Runbook-Aktion. Bei hoher Downstream-Fehlerquote wird pausiert. Bei Audit-Pflicht wird alte Betriebsinformation archiviert statt gelöscht.

Solche Tests sind wertvoll, weil sie verhindern, dass Betriebsregeln unbemerkt verwässert werden. Wenn jemand die Schwelle für Backpressure ändert, muss er bewusst entscheiden, ob die bisherigen Szenarien noch gelten. Das ist ähnlich wie fachliche Tests bei Domain-Logik, nur für Betrieb.

Eine wichtige Grenze bleibt: Diese Tests ersetzen keine echte Lastumgebung. Sie prüfen Entscheidungslogik. Lasttests, Chaos-Tests und Broker-Integrationstests wären zusätzliche Ebenen. Trotzdem ist ein testbares Runbook ein großer Schritt weg von informellem Betriebswissen.

10. Produktionscheckliste für Messaging-Betrieb

Vor Produktion sollten mindestens diese Fragen beantwortet sein: Welches SLO gilt pro Topic? Welche Metriken werden gemessen? Gibt es Alerts mit Handlungsanweisung? Wie werden DLQ-Events klassifiziert? Wer darf Reprocessing starten? Wie wird ein Consumer gestoppt? Wie wird ein Schema-Rollout geprüft? Wie wird ein Hot Key erkannt? Was passiert, wenn der Downstream langsam wird?

Die Checkliste ist bewusst streng. Messaging ist kein reines Entwicklungsfeature. Es ist eine Betriebsverpflichtung. Ein Topic ohne Runbook ist technische Schuld. Eine DLQ ohne Verantwortlichen ist eine zweite Datenbank mit Problemfällen. Ein Retry ohne Backpressure ist eine potenzielle DDoS-Attacke gegen das eigene System.

Für Enterprise Java heißt das: Code, Architektur und Betrieb gehören zusammen. Application Services erzeugen Events, Outbox schützt Konsistenz, Consumer verarbeiten idempotent, Monitoring macht Zustand sichtbar, Runbooks machen Entscheidungen wiederholbar.

Code-Anhang - Kurzüberblick

Der vollständige Code liegt unter code/messaging-operations-deep-dive-lab. Im PDF wird der Code bewusst gekürzt; HTML und ZIP enthalten die vollständigen Dateien.

Vertiefende Lernblöcke

Lernblock 1: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 2: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 3: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 4: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 5: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 6: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 7: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 8: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 9: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Lernblock 10: Betriebsfrage und Entscheidung

Frage: Was passiert, wenn Consumer-Lag steigt, aber DLQ leer bleibt?
Analyse: Dann ist nicht automatisch ein Datenfehler wahrscheinlich. Man prüft Produktionstempo, Consumer-Throughput, Event-Alter und Downstream-Latenz. Lag kann durch mehr Input entstehen, durch einen langsamen Consumer oder durch eine Hot Partition.
Gute Entscheidung: Erst SLO und Max Event Age prüfen, dann Partitionen und Downstream. Nicht sofort Reprocessing starten.
Typischer Fehler: Skalierung erzwingen, obwohl ein einzelner Partition Key alles blockiert.
Produktionshinweis: Jede Aktion muss eine Gegenmetrik haben: Wenn skaliert wird, muss Throughput steigen oder Event Age sinken.

Code-Labs zu diesem Kapitel

Legacy modernisierenCharakterisierung, Extraktion, Parallelbetrieb, Cutover und Cleanup.
11. Legacy-Monster-Code und Characterization Tests

Kapitelkompass

Erst Verhalten sichern, dann verbessern

Characterization Tests beschreiben das tatsächliche Verhalten eines Legacy-Systems, auch wenn es fachlich unbequem ist.

BeobachtenAusgangspunktTestenEinordnenSeamAbsichernRefactorErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Beobachtbares Verhalten sichern
  • Seams finden
  • Golden Master begrenzen
  • Risiken priorisieren

Praxisfall

Ein alter Order-Prozessor mischt Preis, Steuer, Zahlung und Seiteneffekte in einer Methode.

Entscheidung

Zuerst kritische Pfade und Seiteneffekte charakterisieren, nicht jede Zeile konservieren.

!

Typisches Risiko

Ein Test auf interne Aufrufreihenfolge friert Struktur statt fachliches Verhalten ein.

1. Ziel von Kapitel: Legacy verstehen, nicht sofort reparieren

Legacy Monster Map

Kapitel startet den Legacy-Refactoring-Deep-Dive. Der wichtigste Gedanke ist unbequem: Ein großes Legacy-Stück wird nicht besser, nur weil man sofort Klassen extrahiert. Ohne Sicherheitsnetz produziert Refactoring oft neue Fehler, weil man unbeabsichtigt altes Verhalten verändert. Deshalb behandelt dieser Kapitel die Monster-Methode zuerst wie ein Produktionssystem: Verhalten beobachten, Eingaben kontrollieren, externe Effekte erfassen und erst danach schneiden.

In vielen Enterprise-Systemen ist die problematische Methode nicht zufällig entstanden. Sie ist über Jahre gewachsen: Rabatte wurden ergänzt, Steuerregeln geändert, Payment-Anbieter gewechselt, Rechnungslogik erweitert, Events angebaut und Auditpflichten nachträglich eingebaut. Die Methode wirkt schlecht, aber sie enthält oft viel implizites Fachwissen. Wenn man dieses Wissen nicht sichtbar macht, verliert man es beim Refactoring.

Der zentrale Lernpunkt: Characterization Tests beschreiben, was der Code heute tut. Sie sagen noch nicht, ob das Verhalten fachlich schön ist. Sie verhindern zuerst, dass man versehentlich bestehendes Verhalten bricht. Später kann man dokumentiert entscheiden, welche alten Bugs bewusst geändert werden.

Schlechte typische Vorgehensweise

  1. Monster-Methode öffnen.
  2. Sich über schlechte Struktur ärgern.
  3. Sofort extract method und extract class anwenden.
  4. Tests fehlen oder prüfen nur Happy Path.
  5. Produktion findet die Sonderfälle.

Bessere Vorgehensweise

  1. fachliche Szenarien sammeln.
  2. externe Abhängigkeiten über Seams kontrollieren.
  3. deterministische Daten aufbauen.
  4. Golden-Master-Snapshots erzeugen.
  5. Verhalten konservieren.
  6. erst dann kleine Refactoring-Schritte durchführen.
TEXT
Legacy-Code -> Characterization Tests -> kleine Refactoring-Schritte -> gleiche Tests -> Architektur verbessern

2. Die Monster-Methode als reales Lernobjekt

Die Klasse LegacyOrderProcessor ist absichtlich nicht schön. Sie mischt Validierung, Preisberechnung, Rabattlogik, Steuerlogik, Lagerreservierung, Payment, Rechnungsanlage, Eventversand und Audit. Genau so sehen viele echte Legacy-Methoden aus: nicht als Spielzeugbeispiel, sondern als gewachsene Transaktionsskripte.

Auszug aus dem echten Code-Lab:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.ZoneOffset;
import java.util.*;
// Pattern: Legacy Facade (absichtlich) - diese Klasse zeigt eine Monster-Methode als Lernobjekt.
// Sie ist NICHT das Zielbild. Kapitel nutzt sie, um Verhalten abzusichern, bevor Refactoring beginnt.
public final class LegacyOrderProcessor {
    private final LegacyIntegrationGateway gateway;
    public LegacyOrderProcessor(LegacyIntegrationGateway gateway) {
        this.gateway = Objects.requireNonNull(gateway);
    }
// Anti-Pattern: Long Method + Mixed Responsibilities + Hidden Transaction Script.
// Zweck in diesem Kapitel: vorhandenes Verhalten sichtbar und testbar machen.
public LegacyInvoiceResult process(LegacyOrderRequest request) {
    List<String> localEvents = new ArrayList<>();
    List<String> localAudit = new ArrayList<>();
    String orderId = request == null ? "<null>" : request.orderId();
    try {
        localAudit.add("start process order=" + orderId);
        if (request == null) {
            localAudit.add("request is null");
            return LegacyInvoiceResult.failed("<null>", "INVALID_REQUEST", "request must not be null", localEvents, localAudit);
        }
    if (blank(request.orderId())) {
        localAudit.add("orderId missing");
        return LegacyInvoiceResult.failed("<missing>", "INVALID_ORDER_ID", "orderId is required", localEvents, localAudit);
    }
if (blank(request.customerId())) {
    localAudit.add("customerId missing");
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_CUSTOMER", "customerId is required", localEvents, localAudit);
}
if (request.lines() == null || request.lines().isEmpty()) {
    localAudit.add("no lines");
    return LegacyInvoiceResult.failed(request.orderId(), "EMPTY_ORDER", "at least one line is required", localEvents, localAudit);
}
if (request.riskyAccount()) {
    localAudit.add("risky account blocked customer=" + request.customerId());
    localEvents.add("OrderRejected:risky-account");
    gateway.audit("risky account rejected " + request.customerId());
    return LegacyInvoiceResult.failed(request.orderId(), "CUSTOMER_RISK_BLOCK", "customer is blocked by risk checks", localEvents, merge(localAudit, gateway));
}
BigDecimal subtotal = BigDecimal.ZERO;
BigDecimal digitalSubtotal = BigDecimal.ZERO;
BigDecimal physicalSubtotal = BigDecimal.ZERO;
int physicalQuantity = 0;
int totalQuantity = 0;
Set<String> skusSeen = new LinkedHashSet<>();
boolean containsTaxReduced = false;
boolean containsHazardous = false;
boolean containsGiftEligible = false;
for (int i = 0; i < request.lines().size(); i++) {
    OrderLineRequest line = request.lines().get(i);
    if (line == null) {
        return LegacyInvoiceResult.failed(request.orderId(), "INVALID_LINE", "line " + i + " is null", localEvents, localAudit);
    }
if (blank(line.sku())) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_SKU", "line " + i + " has no sku", localEvents, localAudit);
}
if (line.quantity() <= 0) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_QUANTITY", "quantity must be positive for " + line.sku(), localEvents, localAudit);
}
if (line.unitPrice() == null || line.unitPrice().signum() < 0) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_PRICE", "price invalid for " + line.sku(), localEvents, localAudit);
}
if (!skusSeen.add(line.sku())) {
    localAudit.add("duplicate sku merged logically=" + line.sku());
}
BigDecimal lineTotal = line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())).setScale(2, RoundingMode.HALF_UP);
subtotal = subtotal.add(lineTotal);
totalQuantity += line.quantity();
if (line.digital()) {
    digitalSubtotal = digitalSubtotal.add(lineTotal);
}
else {
    physicalSubtotal = physicalSubtotal.add(lineTotal);
    physicalQuantity += line.quantity();
    int available = gateway.availableStock(line.sku());
    localAudit.add("stock check sku=" + line.sku() + " requested=" + line.quantity() + " available=" + available);
    if (available < line.quantity()) {
        localEvents.add("OrderRejected:stock-unavailable:" + line.sku());

Diese Methode zeigt mehrere gefährliche Eigenschaften:

Problem Warum es weh tut
viele Verantwortungen Änderungen an Steuerlogik können Payment-Verhalten beeinflussen
externe Effekte mitten in der Methode Tests brauchen Payment, Lager, Eventbroker und Zeitkontrolle
lokale Sonderregeln Fachwissen ist nicht dokumentiert, sondern im Code versteckt
Fehlercodes als Strings API, UI und Batchjobs hängen oft implizit daran
Reihenfolge wichtig Lagerreservierung vor Payment kann bei Payment-Fehlern problematisch sein

Die Methode enthält außerdem Legacy-Verhalten, das man nicht blind korrigieren darf. Beispiel: gemischte Steuerkategorien werden mit einem vereinfachten Legacy-Satz behandelt. Vielleicht ist das falsch, vielleicht ist es historisch gewollt. Kapitel ändert es nicht, sondern macht es sichtbar.

3. Characterization Tests: Verhalten einfrieren, ohne Architektur zu feiern

Characterization Loop

Ein Characterization Test ist kein idealer fachlicher Test. Er ist eine Sicherheitsleine. Er sagt: Für diese Eingabe produziert der alte Code diese beobachtbare Ausgabe. Das ist besonders wertvoll, wenn niemand sicher sagen kann, welche Sonderfälle absichtlich sind.

Der Test muss dabei nicht jedes Logdetail festnageln. Ein zu genauer Golden Master wird brüchig. Ein zu grober Golden Master übersieht Fehler. Die Kunst liegt in der Auswahl stabiler Signale:

  • Erfolg oder Fehler
  • Fehlercode
  • Rechnungsnummer, wenn deterministisch
  • Subtotal, Rabatt, Versand, Steuer, Gesamtbetrag
  • Domain Events
  • wichtige Nebenwirkungen wie Payment-Autorisierung oder Lagerreservierung
JAVA
package com.example.legacy.Kapitel;
import java.util.stream.Collectors;
// Pattern: Golden Master - vergleicht beobachtbares Verhalten, nicht interne Struktur.
public final class GoldenMasterSnapshot {
    private GoldenMasterSnapshot() {
    }
public static String from(LegacyInvoiceResult result) {
    String events = result.domainEvents().stream().collect(Collectors.joining(","));
    String auditSignal = result.auditTrail().stream()
    .filter(a -> a.contains("stock check") || a.contains("reserved") || a.contains("authorize payment") || a.contains("invoice saved"))
    .collect(Collectors.joining(" | "));
    return "success=" + result.success()
    + ";order=" + result.orderId()
    + ";invoice=" + result.invoiceId()
    + ";subtotal=" + result.subtotal().toPlainString()
    + ";discount=" + result.discount().toPlainString()
    + ";shipping=" + result.shipping().toPlainString()
    + ";tax=" + result.tax().toPlainString()
    + ";total=" + result.total().toPlainString()
    + ";failure=" + result.failureCode()
    + ";events=" + events
    + ";auditSignal=" + auditSignal;
}
}

Der Snapshot filtert bewusst technische Details. Er nimmt nur relevante Audit-Signale, nicht jede Debug-Zeile. So schützt er Verhalten, ohne Refactoring unnötig zu blockieren.

4. Seams: externe Abhängigkeiten kontrollierbar machen

Seam Map

Legacy-Code ist schwer testbar, wenn er direkt auf Uhrzeit, Datenbank, Payment, Messaging oder Dateisystem zugreift. Eine Seam ist eine Stelle, an der man Verhalten austauschen kann, ohne den fachlichen Ablauf sofort umzubauen.

In Kapitel kapselt LegacyIntegrationGateway die externen Effekte:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.time.Instant;
// Pattern: Seam - kapselt externe Zeit-, Lager-, Payment-, Invoice- und Event-Abhängigkeiten für Characterization Tests.
public interface LegacyIntegrationGateway {
    Instant now();
    int availableStock(String sku);
    boolean reserveStock(String orderId, String sku, int quantity);
    PaymentDecision authorizePayment(String orderId, String customerId, String paymentMethod, BigDecimal total);
    String nextInvoiceId();
    void saveInvoice(String invoiceId, BigDecimal total);
    void publishEvent(String eventType, String payload);
    void audit(String message);
}

Die Fake-Implementierung macht das System deterministisch:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
// Pattern: Fake - deterministische Test-Implementierung ersetzt externe Systeme ohne Mock-Rauschen.
public final class DeterministicLegacyGateway implements LegacyIntegrationGateway {
    private final Instant fixedNow;
    private final Map<String, Integer> stock = new LinkedHashMap<>();
    private final List<String> events = new ArrayList<>();
    private final List<String> audit = new ArrayList<>();
    private final Set<String> declinedPaymentMethods = new HashSet<>();
    private int invoiceSequence = 1000;
    public DeterministicLegacyGateway(Instant fixedNow) {
        this.fixedNow = fixedNow;
    }
public DeterministicLegacyGateway withStock(String sku, int quantity) {
    stock.put(sku, quantity);
    return this;
}
public DeterministicLegacyGateway declinePaymentMethod(String paymentMethod) {
    declinedPaymentMethods.add(paymentMethod);
    return this;
}
@Override public Instant now() {
    return fixedNow;
}
@Override public int availableStock(String sku) {
    return stock.getOrDefault(sku, 0);
}
@Override public boolean reserveStock(String orderId, String sku, int quantity) {
    int current = stock.getOrDefault(sku, 0);
    if (current < quantity) return false;
    stock.put(sku, current - quantity);
    audit("reserved " + quantity + " of " + sku + " for " + orderId);
    return true;
}
@Override public PaymentDecision authorizePayment(String orderId, String customerId, String paymentMethod, BigDecimal total) {
    audit("authorize payment " + paymentMethod + " total=" + total.toPlainString());
    if (declinedPaymentMethods.contains(paymentMethod)) {
        return PaymentDecision.declined("PAYMENT_PROVIDER_DECLINED");
    }
if (total.compareTo(new BigDecimal("7500.00")) > 0) {
    return PaymentDecision.declined("LIMIT_EXCEEDED");
}
return PaymentDecision.approved("TX-" + orderId + "-" + fixedNow.toString().replace(":", ""));
}
@Override public String nextInvoiceId() {
    return "INV-" + (++invoiceSequence);
}
@Override public void saveInvoice(String invoiceId, BigDecimal total) {
    audit("invoice saved " + invoiceId + " total=" + total.toPlainString());
}
@Override public void publishEvent(String eventType, String payload) {
    events.add(eventType + "|" + payload);
    audit("event published " + eventType);
}
@Override public void audit(String message) {
    audit.add(fixedNow + " " + message);
}
public List<String> events() {
    return List.copyOf(events);
}
public List<String> auditTrail() {
    return List.copyOf(audit);
}
}

Produktionshinweis: Eine Seam ist nicht automatisch schöne Architektur. Sie ist oft nur der erste sichere Schnitt. Später wird daraus vielleicht ein sauberer Port, ein Adapter, ein Repository oder ein Domain Service. In Kapitel zählt zuerst: Tests müssen ohne echte Infrastruktur laufen.

5. Szenario-Katalog: Fachsprache statt technische Testnamen

Gute Legacy-Tests heißen nicht test1, testHappyPath oder processWorks. Sie heißen nach fachlichen Situationen. Das hilft beim Lesen und später beim Refactoring.

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.util.List;
// Pattern: Test Data Builder / Object Mother - zentrale, lesbare fachliche Szenarien statt kopierter Testdaten.
public final class ScenarioCatalog {
    private ScenarioCatalog() {
    }
public static LegacyOrderRequest platinumHappyPath() {
    return new LegacyOrderRequest("ORD-1001", "C-77", "PLATINUM", "DE", "VIP20",
    true, true, false, "CARD_OK", List.of(
    new OrderLineRequest("BOOK-1", 2, new BigDecimal("29.90"), false, true, "REDUCED"),
    new OrderLineRequest("CHAIR-9", 1, new BigDecimal("149.00"), false, true, "STANDARD")));
}
public static LegacyOrderRequest insufficientStock() {
    return new LegacyOrderRequest("ORD-1002", "C-88", "STANDARD", "DE", null,
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("RARE-1", 5, new BigDecimal("12.00"), false, false, "STANDARD")));
}
public static LegacyOrderRequest invalidCoupon() {
    return new LegacyOrderRequest("ORD-1003", "C-99", "SILVER", "DE", "NOPE",
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("BOOK-1", 1, new BigDecimal("20.00"), false, true, "REDUCED")));
}
public static LegacyOrderRequest digitalOnlyEuCustomer() {
    return new LegacyOrderRequest("ORD-1004", "C-11", "GOLD", "AT", "WELCOME10",
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("EBOOK-7", 3, new BigDecimal("19.99"), true, false, "DIGITAL")));
}
public static LegacyOrderRequest paymentDeclined() {
    return new LegacyOrderRequest("ORD-1005", "C-55", "STANDARD", "DE", null,
    false, false, false, "CARD_DECLINE", List.of(
    new OrderLineRequest("CHAIR-9", 1, new BigDecimal("149.00"), false, true, "STANDARD")));
}
public static LegacyOrderRequest hazardousCrossBorder() {
    return new LegacyOrderRequest("ORD-1006", "C-12", "STANDARD", "AT", null,
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("HZ-CLEANER", 1, new BigDecimal("9.99"), false, false, "STANDARD")));
}
}

Der Szenario-Katalog ist bewusst breit:

  • Premium-Kunde mit Rabatt, Geschenkverpackung, Lager und Payment
  • Lagerbestand reicht nicht
  • Coupon ist ungültig
  • rein digitale Bestellung aus EU-Ausland
  • Payment wird abgelehnt
  • Gefahrgut ins Ausland wird blockiert

Diese Szenarien prüfen nicht nur Rückgabewerte. Sie prüfen auch Reihenfolge und Nebenwirkungen. Genau dort entstehen viele Legacy-Fehler.

6. Golden-Master-Strategie: Was vergleichen, was ignorieren?

Golden Master Scope

Ein schlechter Golden Master vergleicht alles: komplette Logdateien, zufällige IDs, echte Uhrzeiten, JSON-Feldreihenfolge, Stacktraces und Providertexte. Dadurch wird jeder Refactoring-Schritt zur Qual.

Ein guter Golden Master vergleicht stabile fachliche Signale. In Kapitel enthält die Snapshot-Zeile bewusst:

TEXT
success, order, invoice, subtotal, discount, shipping, tax, total, failure, events, auditSignal

Warum nicht mehr? Weil Details wie vollständige Audittexte später anders entstehen dürfen. Warum nicht weniger? Weil Beträge, Fehlercodes und Events genau die vertraglichen Punkte sind, an denen andere Systeme hängen.

Die Tests selbst sind bewusst ohne JUnit geschrieben, damit sie in dieser Umgebung JDK-only kompilieren und laufen:

JAVA
package com.example.legacy.Kapitel;
import java.util.LinkedHashMap;
import java.util.Map;
public final class LegacyCharacterizationTestRunner {
    public static void main(String[] args) {
        Map<String, String> expected = new LinkedHashMap<>();
        expected.put("platinumHappyPath", "success=true;order=ORD-1001;invoice=INV-1001;subtotal=208.80;discount=62.64;shipping=15.50;tax=17.54;total=179.20;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1001;auditSignal=stock check sku=BOOK-1 requested=2 available=20 | stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 2 of BOOK-1 for ORD-1001 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1001 | 2026-02-02T10:15:30Z authorize payment CARD_OK total=179.20 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=179.20");
        expected.put("insufficientStock", "success=false;order=ORD-1002;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=STOCK_UNAVAILABLE;events=OrderRejected:stock-unavailable:RARE-1;auditSignal=stock check sku=RARE-1 requested=5 available=1");
        expected.put("invalidCoupon", "success=false;order=ORD-1003;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=INVALID_COUPON;events=OrderRejected:invalid-coupon:NOPE;auditSignal=stock check sku=BOOK-1 requested=1 available=20");
        expected.put("digitalOnlyEuCustomer", "success=true;order=ORD-1004;invoice=INV-1001;subtotal=59.97;discount=14.20;shipping=0.00;tax=0.00;total=45.77;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1004;auditSignal=2026-02-02T10:15:30Z authorize payment CARD_OK total=45.77 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=45.77");
        expected.put("paymentDeclined", "success=false;order=ORD-1005;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=PAYMENT_DECLINED;events=OrderRejected:payment:PAYMENT_PROVIDER_DECLINED;auditSignal=stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1005 | 2026-02-02T10:15:30Z authorize payment CARD_DECLINE total=177.31");
        expected.put("hazardousCrossBorder", "success=false;order=ORD-1006;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=HAZARDOUS_CROSS_BORDER;events=OrderRejected:hazardous-cross-border;auditSignal=stock check sku=HZ-CLEANER requested=1 available=8");
        assertSnapshot("platinumHappyPath", expected, ScenarioCatalog.platinumHappyPath());
        assertSnapshot("insufficientStock", expected, ScenarioCatalog.insufficientStock());
        assertSnapshot("invalidCoupon", expected, ScenarioCatalog.invalidCoupon());
        assertSnapshot("digitalOnlyEuCustomer", expected, ScenarioCatalog.digitalOnlyEuCustomer());
        assertSnapshot("paymentDeclined", expected, ScenarioCatalog.paymentDeclined());
        assertSnapshot("hazardousCrossBorder", expected, ScenarioCatalog.hazardousCrossBorder());
        System.out.println("Kapitel");
    }
private static void assertSnapshot(String name, Map<String, String> expected, LegacyOrderRequest request) {
    DeterministicLegacyGateway gateway = LegacyTestFixture.gateway();
    LegacyOrderProcessor processor = LegacyTestFixture.processor(gateway);
    String actual = GoldenMasterSnapshot.from(processor.process(request));
    String wanted = expected.get(name);
    if (!wanted.equals(actual)) {
        throw new AssertionError("Snapshot mismatch for " + name + "\nExpected:\n" + wanted + "\nActual:\n" + actual);
    }
}
}

7. Schmerzhafte Beobachtung: Tests konservieren auch Bugs

Characterization Tests frieren Verhalten ein. Das ist Absicht, aber es hat eine Nebenwirkung: Sie konservieren auch historisches Fehlverhalten. Beispiel in der Monster-Methode:

JAVA
if (containsTaxReduced && "DE".equalsIgnoreCase(request.country())) {
    taxRate = new BigDecimal("0.12");
    // Legacy bug/behavior: mixed basket gets blended simplified rate.
    localAudit.add("legacy blended reduced tax rate 12%");
}

Ist das fachlich korrekt? Vielleicht nicht. Aber wenn Rechnungen seit Jahren so erzeugt werden, darf ein Refactoring diese Regel nicht heimlich ändern. Stattdessen braucht man eine Entscheidung:

Fall Vorgehen
Verhalten ist korrekt Snapshot bleibt
Verhalten ist Bug, aber vorerst kompatibel nötig Snapshot bleibt, Bug dokumentieren
Verhalten soll geändert werden neuer expliziter fachlicher Test, Migrationsnotiz, Release-Kommunikation

Das ist der Unterschied zwischen Refactoring und fachlicher Änderung. Refactoring soll Struktur ändern, nicht Verhalten. Fachliche Änderung muss sichtbar entschieden werden.

8. Typische Fehler bei Legacy-Characterization

Fehler 1: Nur Happy Path testen

Der Happy Path ist oft der am wenigsten riskante Teil. Die gefährlichen Stellen sind Abbruchpfade: ungültiger Coupon, Payment abgelehnt, Lagerbestand zu knapp, externe Systeme nicht erreichbar.

Fehler 2: Echte Infrastruktur im Test verwenden

Wenn der Test echten Payment, echte Uhrzeit oder echte Datenbank braucht, ist er langsam und instabil. Eine Seam oder Fake ist kein Luxus, sondern Voraussetzung.

Fehler 3: Snapshot zu breit machen

Ein Snapshot, der jeden internen Logeintrag prüft, verhindert Refactoring. Man sollte fachliche Signale prüfen, nicht Implementierungsrauschen.

Fehler 4: Alte Bugs versehentlich korrigieren

Das klingt gut, ist aber gefährlich. Wer beim Refactoring fachliche Bugs ändert, braucht Migrationsentscheidung, Datenkorrektur und Kommunikation. Sonst ist es kein Refactoring mehr.

Fehler 5: Testdaten kopieren

Wenn jedes Szenario 40 Zeilen Testdaten kopiert, versteht niemand mehr den Unterschied. Ein Szenario-Katalog hält die Fachsprache sichtbar.

9. Code-Walkthrough: Warum diese Methode schwer zu refactoren ist

Die Methode ist schwer zu refactoren, weil die Reihenfolge fachlich relevant ist:

TEXT
Validierung -> Stock Check -> Rabatt -> Versand -> Steuer -> Reservierung -> Payment -> Invoice -> Events

Eine naive Extraktion könnte zum Beispiel Payment vor Reservierung ziehen. Das sieht sauberer aus, ändert aber Verhalten. Oder sie könnte Steuerberechnung isolieren und dabei die Legacy-Sonderregel für reduzierte Steuer verlieren.

Weitere kritische Stelle:

JAVA
shipping = new BigDecimal("4.90");
if (physicalSubtotal.compareTo(new BigDecimal("100.00")) > 0) shipping = BigDecimal.ZERO;
if (request.expressShipping()) shipping = shipping.add(new BigDecimal("12.00"));
if ("FREESHIP".equalsIgnoreCase(String.valueOf(request.couponCode()))) shipping = BigDecimal.ZERO;
if (!"DE".equalsIgnoreCase(request.country())) shipping = shipping.add(new BigDecimal("9.00"));
}
if (request.giftWrap()) {
    if (!containsGiftEligible) {
        return LegacyInvoiceResult.failed(request.orderId(), "GIFT_WRAP_NOT_ALLOWED", "no gift eligible line", localEvents, localAudit);
    }
shipping = shipping.add(new BigDecimal("3.50"));
}
shipping = shipping.setScale(2, RoundingMode.HALF_UP);
BigDecimal taxableBase = subtotal.subtract(discount).max(BigDecimal.ZERO);
BigDecimal taxRate = "DE".equalsIgnoreCase(request.country()) ? new BigDecimal("0.19") : new BigDecimal("0.00");
if (containsTaxReduced && "DE".equalsIgnoreCase(request.country())) {
    taxRate = new BigDecimal("0.12");
    // Legacy bug/behavior: mixed basket gets blended simplified rate.
    localAudit.add("legacy blended reduced tax rate 12%");
}
BigDecimal tax = taxableBase.multiply(taxRate).setScale(2, RoundingMode.HALF_UP);
BigDecimal total = taxableBase.add(shipping).add(tax).setScale(2, RoundingMode.HALF_UP);
DayOfWeek dow = gateway.now().atZone(ZoneOffset.UTC).getDayOfWeek();
if (request.expressShipping() && (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY)) {
    localAudit.add("express weekend handling fee");
    total = total.add(new BigDecimal("5.00")).setScale(2, RoundingMode.HALF_UP);
}
for (OrderLineRequest line : request.lines()) {
    if (!line.digital()) {
        boolean reserved = gateway.reserveStock(request.orderId(), line.sku(), line.quantity());
        if (!reserved) {
            localEvents.add("OrderRejected:race-stock-unavailable:" + line.sku());
            return LegacyInvoiceResult.failed(request.orderId(), "STOCK_RACE_LOST", "stock changed for " + line.sku(), localEvents, merge(localAudit, gateway));
        }
}
}
PaymentDecision payment = gateway.authorizePayment(request.orderId(), request.customerId(), request.paymentMethod(), total);
if (!payment.approved()) {
    localEvents.add("OrderRejected:payment:" + payment.reason());
    return LegacyInvoiceResult.failed(request.orderId(), "PAYMENT_DECLINED", payment.reason(), localEvents, merge(localAudit, gateway));
}
String invoiceId = gateway.nextInvoiceId();
gateway.saveInvoice(invoiceId, total);
String invoicePayload = request.orderId() + ":" + invoiceId + ":" + total.toPlainString();
gateway.publishEvent("InvoiceCreated", invoicePayload);
gateway.publishEvent("OrderAccepted", request.orderId() + ":" + payment.transactionId());
localEvents.add("InvoiceCreated:" + invoiceId);
localEvents.add("OrderAccepted:" + request.orderId());
localAudit.add("finished success invoice=" + invoiceId + " total=" + total.toPlainString());
return new LegacyInvoiceResult(true, request.orderId(), invoiceId, subtotal.setScale(2, RoundingMode.HALF_UP), discount,
shipping, tax, total, "-", "-", List.copyOf(localEvents), merge(localAudit, gateway));
}
catch (RuntimeException ex) {
    localAudit.add("technical exception " + ex.getClass().getSimpleName() + ":" + ex.getMessage());
    return LegacyInvoiceResult.failed(orderId, "TECHNICAL_FAILURE", ex.getMessage(), localEvents, merge(localAudit, gateway));
}
}
private static boolean blank(String text) {
    return text == null || text.trim().isEmpty();
}
private static List<String> merge(List<String> local, LegacyIntegrationGateway gateway) {
    List<String> merged = new ArrayList<>(local);
    if (gateway instanceof DeterministicLegacyGateway d) merged.addAll(d.auditTrail());
    return List.copyOf(merged);
}
}

Hier hängen Rabatt, Versand und Steuer zusammen. Man kann diese Logik später trennen, aber erst wenn Tests zeigen, welche Kombinationen wichtig sind. Vorher wäre jede Extraktion Spekulation.

10. Kapitel als Vorbereitung für Kapitel

Kapitel ist nicht das Ende, sondern das Sicherheitsnetz für die nächsten Vertiefungen. Kapitel kann danach die ersten Verantwortungen extrahieren:

  1. Validation Service
  2. Pricing Policy
  3. Shipping Calculator
  4. Tax Calculator
  5. Inventory Port
  6. Payment Port
  7. Invoice Service
  8. Outbox/Audit Service

Aber jede Extraktion muss die bestehenden Snapshots weiter bestehen. Sobald ein Snapshot bricht, muss man entscheiden:

  • Refactoring-Fehler?
  • Snapshot war zu eng?
  • fachliche Änderung bewusst gewünscht?

Diese Disziplin macht aus chaotischem Legacy-Code eine kontrollierte Modernisierung.

11. Produktionscheckliste für Legacy-Refactoring

Vor dem ersten produktionsnahen Refactoring sollten diese Punkte erfüllt sein:

TEXT
[ ] wichtigste fachliche Szenarien benannt
[ ] externe Abhängigkeiten kontrollierbar
[ ] Zeit deterministisch
[ ] Payment und Lager fakebar
[ ] Golden-Master-Snapshots genehmigt
[ ] bekannte Bugs dokumentiert
[ ] Logik mit Nebenwirkungen markiert
[ ] Codepfade mit Payment, Invoice, Event und Audit getestet
[ ] Refactoring-Schritte klein genug
[ ] Rollback-Plan vorhanden

Die wichtigste Regel lautet: Kein großes Refactoring ohne schnelle lokale Tests. Wenn Tests mehrere Minuten dauern oder Infrastruktur brauchen, werden Entwickler sie nicht nach jedem kleinen Schritt ausführen.

12. Nichtdeterminismus: der unsichtbare Feind von Legacy-Tests

Nichtdeterminismus Matrix

Legacy-Code ist selten nur wegen langer Methoden schwer zu testen. Oft ist er schwer zu testen, weil sein Ergebnis von unsichtbaren Dingen abhängt: aktueller Uhrzeit, zufälligen IDs, externer Payment-Antwort, Datenbankzustand, Message-Broker-Verfügbarkeit oder sogar Reihenfolge von Logeinträgen.

Ein Characterization Test muss diese Quellen kontrollieren. Sonst schlägt der Test nicht fehl, weil Verhalten falsch ist, sondern weil die Umgebung anders war. Kapitel kontrolliert deshalb Zeit, Lager, Payment, Invoice-Sequenz, Events und Audit über DeterministicLegacyGateway.

Typische Nichtdeterminismus-Quellen

Quelle Beispiel Gegenmaßnahme
Uhrzeit Wochenende erzeugt Zusatzgebühr feste Clock / Gateway
Sequenz Rechnungsnummer ändert sich deterministische Sequenz
Payment Provider lehnt zufällig ab Fake mit festen Regeln
Lager anderer Test verändert Bestand frisches Fixture pro Szenario
Events Broker sendet asynchron lokale Eventliste im Fake
Logging Reihenfolge oder Threadnamen ändern sich nur fachliche Audit-Signale vergleichen

Wichtig ist: Man faked nicht, um sich die Welt schönzureden. Man faked, um das beobachtbare Verhalten reproduzierbar zu machen.

13. Nebenwirkungen sichtbar machen: warum Rückgabewerte nicht reichen

Side Effect Timeline

Viele einfache Tests prüfen nur den Rückgabewert. Bei Enterprise-Legacy reicht das nicht. Eine Bestellung kann scheinbar korrekt fehlschlagen und trotzdem vorher Lager reserviert haben. Oder eine Rechnung kann gespeichert sein, aber das Event fehlt. Genau deshalb prüft Kapitel ausgewählte Nebenwirkungen.

In paymentDeclined ist das Verhalten bewusst unangenehm: Lager wird reserviert, danach lehnt Payment ab. Ob das fachlich gut ist, ist eine spätere Entscheidung. Für Kapitel ist wichtig, dass dieses Verhalten sichtbar wird.

JAVA
expected.put("insufficientStock", "success=false;order=ORD-1002;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=STOCK_UNAVAILABLE;events=OrderRejected:stock-unavailable:RARE-1;auditSignal=stock check sku=RARE-1 requested=5 available=1");
expected.put("invalidCoupon", "success=false;order=ORD-1003;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=INVALID_COUPON;events=OrderRejected:invalid-coupon:NOPE;auditSignal=stock check sku=BOOK-1 requested=1 available=20");
expected.put("digitalOnlyEuCustomer", "success=true;order=ORD-1004;invoice=INV-1001;subtotal=59.97;discount=14.20;shipping=0.00;tax=0.00;total=45.77;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1004;auditSignal=2026-02-02T10:15:30Z authorize payment CARD_OK total=45.77 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=45.77");
expected.put("paymentDeclined", "success=false;order=ORD-1005;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=PAYMENT_DECLINED;events=OrderRejected:payment:PAYMENT_PROVIDER_DECLINED;auditSignal=stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1005 | 2026-02-02T10:15:30Z authorize payment CARD_DECLINE total=177.31");
expected.put("hazardousCrossBorder", "success=false;order=ORD-1006;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=HAZARDOUS_CROSS_BORDER;events=OrderRejected:hazardous-cross-border;auditSignal=stock check sku=HZ-CLEANER requested=1 available=8");
assertSnapshot("platinumHappyPath", expected, ScenarioCatalog.platinumHappyPath());
assertSnapshot("insufficientStock", expected, ScenarioCatalog.insufficientStock());
assertSnapshot("invalidCoupon", expected, ScenarioCatalog.invalidCoupon());
assertSnapshot("digitalOnlyEuCustomer", expected, ScenarioCatalog.digitalOnlyEuCustomer());
assertSnapshot("paymentDeclined", expected, ScenarioCatalog.paymentDeclined());
assertSnapshot("hazardousCrossBorder", expected, ScenarioCatalog.hazardousCrossBorder());
System.out.println("Kapitel");
}

Diese Erwartung zeigt nicht nur PAYMENT_DECLINED, sondern auch die vorherige Lagerreservierung. Ohne diese Information könnte Kapitel die Reihenfolge ändern, Tests würden grün bleiben, aber Produktion hätte anderes Verhalten.

14. Snapshot Governance: wann darf ein Golden Master geändert werden?

Snapshot Governance

Ein Golden Master ist mächtig und gefährlich. Wer Snapshots leichtfertig aktualisiert, verliert den Schutz. Wer sie nie aktualisiert, blockiert notwendige Änderungen. Deshalb braucht jedes Team eine einfache Governance.

Entscheidungsbaum

TEXT
Snapshot bricht
  |
  +-- war der Test zu eng? -> Snapshot fachlich stabiler machen
  |
  +-- war es unbeabsichtigter Refactoring-Fehler? -> Code korrigieren
  |
  +-- war es gewünschte fachliche Änderung? -> neue Erwartung, Release Note, ggf. Datenmigration

Bei Legacy-Systemen ist diese Trennung besonders wichtig. Ein Entwickler sieht vielleicht einen offensichtlichen Bug. Aber wenn externe Partner, Buchhaltung oder Reporting seit Jahren davon abhängen, ist die Korrektur kein kleines Refactoring mehr.

15. Refactoring-Risikoleiter: nicht jeder Schritt ist gleich gefährlich

Refactoring Risk Ladder

Ein häufiger Fehler ist, jedes Refactoring gleich zu behandeln. Eine lokale Variablenumbenennung ist nicht so riskant wie eine neue Transaktionsgrenze. Eine Methode ohne Seiteneffekt zu extrahieren ist harmloser als Payment und Lager in anderer Reihenfolge aufzurufen.

Kapitel schafft die Grundlage, damit Kapitel mit niedrigen Risikostufen beginnt:

  1. Namen verbessern.
  2. lokale Hilfsmethoden ohne Seiteneffekt extrahieren.
  3. Guard Clauses ordnen.
  4. reine Berechnungen isolieren.
  5. erst später externe Effekte trennen.

Rote Linie

Sobald ein Schritt Nebenwirkungsreihenfolge, Fehlercode, Rundung, Eventtyp oder Transaktionsgrenze verändert, ist er kein rein mechanischer Schritt mehr. Dann braucht man Review, Testergänzung und oft fachliche Zustimmung.

16. Übungsaufgaben für Kapitel

Diese Aufgaben sind bewusst praxisnah formuliert. Sie sollen nicht nur Wissen abfragen, sondern dich zwingen, das Sicherheitsnetz zu benutzen.

Aufgabe 1: Neues Szenario ergänzen

Ergänze ein Szenario für FREESHIP mit physischer Bestellung über 100 EUR. Prüfe, ob Versand wirklich 0 EUR bleibt und ob die Steuer korrekt berechnet wird.

Aufgabe 2: Snapshot enger oder weiter?

Entscheide, ob der Snapshot die Rechnungsnummer enthalten soll. Argumentiere aus Sicht von Refactoring-Sicherheit und Teststabilität.

Aufgabe 3: Bug dokumentieren

Die gemischte Steuerberechnung nutzt einen vereinfachten Satz. Dokumentiere, ob du das als Bug, Legacy-Vertrag oder offene Fachfrage einstufst.

Aufgabe 4: Eine sichere Extraktion vorbereiten

Markiere im Code einen Block, der ohne Nebenwirkung extrahiert werden kann. Führe noch keine Architekturänderung durch. Beschreibe, warum dieser Block sicher ist.

Aufgabe 5: Eine gefährliche Extraktion erkennen

Warum ist es gefährlich, gateway.authorizePayment(...) vor die Lagerreservierung zu verschieben? Beschreibe mindestens drei mögliche Produktionsfolgen.

17. Was Kapitel bewusst noch nicht löst

Kapitel macht Verhalten sichtbar. Er löst noch nicht die Architektur. Das ist Absicht.

Noch nicht gelöst:

  • keine saubere DDD-Zerlegung
  • keine getrennten Domain Services
  • keine echte Outbox-Integration
  • keine Kompensation für Payment-Fehler nach Lagerreservierung
  • keine Datenbanktransaktionen
  • keine JPA-Entities
  • keine REST-Schicht

Warum nicht? Weil ein großer Umbau ohne Sicherheitsnetz genau das Risiko ist, das Kapitel vermeiden soll. Kapitel darf schneiden, weil Kapitel vorher beobachtbares Verhalten abgesichert hat.

18. Code-Anhang kurz: wichtigste Dateien

Der vollständige Code liegt im ZIP unter code/legacy-characterization-deep-dive-lab/.

Datei Zweck
LegacyOrderProcessor.java Monster-Methode als Ausgangspunkt
LegacyIntegrationGateway.java Seam für externe Effekte
DeterministicLegacyGateway.java Fake für Zeit, Lager, Payment, Invoice, Events
ScenarioCatalog.java fachliche Testdaten
GoldenMasterSnapshot.java stabile Verhaltenssignatur
LegacyCharacterizationTestRunner.java JDK-only Characterization Tests
docs/design-patterns.html Pattern-Dokumentation
docs/refactoring-strategy.html Strategie für Kapitel

Code-Labs zu diesem Kapitel

12. Verantwortung extrahieren und Refactoring Safety

Kapitelkompass

Verantwortung in kleinen Schritten verschieben

Sichere Extraktion trennt fachliche Entscheidungen von Orchestrierung und Infrastruktur, ohne alles gleichzeitig neu zu bauen.

LegacyAusgangspunktExtraktionEinordnenParityAbsichernGrenzeErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Verantwortungen benennen
  • Pure Logic extrahieren
  • Parity vergleichen
  • Grenzen absichern

Praxisfall

Pricing, Tax und Shipping werden aus einem Legacy-Prozessor in eigene Dienste verschoben.

Entscheidung

Mit deterministischer Logik beginnen; I/O und Transaktionsgrenzen später verschieben.

!

Typisches Risiko

Eine neue Service-Klasse ohne eigene Verantwortung ist nur der alte Monolith in mehr Dateien.

Verantwortung extrahieren: Validation, Pricing, Shipping, Tax und sichere Refactoring-Schritte

Legacy-Refactoring-Vertiefung: Kapitel.

01. Warum Kapitel ein eigener Deep Dive ist

Kapitel hat das Sicherheitsnetz aufgebaut: Golden Master, Characterization Tests, Fakes und Szenario-Katalog. Kapitel beginnt jetzt mit dem eigentlichen Schneiden der Monster-Methode. Der wichtige Punkt: Refactoring heisst hier nicht, Code hübscher zu formatieren. Refactoring bedeutet, fachliche Entscheidungen aus einem riskanten Block herauszulösen, ohne das beobachtete Verhalten unkontrolliert zu verändern.

In Enterprise-Systemen sind Monster-Methoden oft nicht deshalb gefährlich, weil sie lang sind. Sie sind gefährlich, weil sie mehrere Arten von Wissen vermischen: fachliche Vorbedingungen, Preisregeln, Steuerlogik, technische Integrationen, Audit, Logging, Transaktionen und Fehlerbehandlung. Wer dort nur extract method ausführt, erzeugt kleinere Methoden, aber nicht automatisch bessere Architektur.

Das Ziel von Kapitel ist deshalb: Verantwortung sichtbar machen. Validierung, Preisfindung, Versand, Steuer, Payment, Invoice und Audit werden getrennt. Jede Verantwortung bekommt eigene Namen, eigene Tests und eigene fachliche Grenzen.

TEXT
Legacy-Methode
  -> Validierung extrahieren
  -> Preisregeln extrahieren
  -> Versand extrahieren
  -> Steuer extrahieren
  -> Nebenwirkungen hinter Ports legen
  -> neuen Application Service parallel betreiben
  -> Parity Tests gegen Legacy-Verhalten laufen lassen

Die wichtigste Lernregel lautet: Nicht zuerst abstrahieren. Zuerst beobachten, benennen, testen, dann schneiden.

02. Die Monster-Methode fachlich lesen

Eine Legacy-Methode hat oft eine scheinbar technische Form: processOrder, saveOrder, createInvoice, handleRequest. Beim Lesen darf man sich nicht von Variablen, if-Blöcken und API-Aufrufen hypnotisieren lassen. Man muss fragen: Welche fachliche Entscheidung wird hier gerade getroffen?

Beispiel aus dem Lab:

JAVA
if (request.segment() == CustomerSegment.BLOCKED) return "REJECTED|CUSTOMER_BLOCKED";
if (request.lines().isEmpty()) return "REJECTED|EMPTY_ORDER";

Das ist keine technische Prüfung. Das ist eine fachliche Vorbedingung: Ein gesperrter Kunde darf nicht bestellen, und eine Bestellung ohne Positionen ist kein gültiger Auftrag. Diese Regeln gehören in eine Validierung, nicht in die Preisberechnung und nicht neben Payment-Code.

Später im Legacy-Code stehen Versandregeln:

JAVA
if (afterDiscount.amount().compareTo(new BigDecimal("250.00")) >= 0 && request.shippingCountry() != Country.US) shipping = Money.eur("0");
if (weight > 10_000) shipping = shipping.add(Money.eur("12.00"));

Auch das ist kein technisches Detail. Es ist fachliche Logistik. Wenn diese Logik im selben Block wie Rabatt, Steuer und Payment steht, kann niemand sicher ändern, ohne unabsichtlich Seiteneffekte auszulösen.

Eine gute Refactoring-Analyse markiert deshalb Zeile für Zeile:

Abschnitt Fachliche Verantwortung Gefahr bei Änderung
Kunde gesperrt Validierung falsche Ablehnung/Annahme
Rabatt berechnen Pricing falsche Rechnung
Versand berechnen Shipping Marge oder Kundenerlebnis falsch
Steuer berechnen Tax Compliance-Risiko
Payment autorisieren externe Nebenwirkung Doppelbuchung oder falscher Status
Rechnung erzeugen externe Nebenwirkung doppelte Rechnung
Audit schreiben Nachvollziehbarkeit fehlende Beweisbarkeit

03. Validation extrahieren, ohne Verhalten zu verlieren

Validierung ist oft der erste gute Schnitt. Sie ist relativ nah an der Eingabe, meist gut testbar und sollte keine externen Nebenwirkungen haben. Trotzdem muss man vorsichtig sein: Viele Legacy-Systeme brechen beim ersten Fehler ab. Moderne Validierung sammelt oft mehrere Fehler. Das ist fachlich besser, aber nicht immer verhaltensgleich.

Darum trennt Kapitel zwei Fragen:

  1. Was war das alte beobachtete Verhalten?
  2. Was ist das bessere Zielverhalten?

Im Code sammelt OrderValidationService mehrere Violations. Für die Parity Tests wird zunächst nur geprüft, ob die fachliche Entscheidung akzeptiert/abgelehnt gleich bleibt. Das ist bewusst: Eine 1:1-String-Gleichheit wäre am Anfang zu streng und würde das Refactoring blockieren.

JAVA
ValidationResult validation = validationService.validate(request);
if (!validation.ok()) {
    auditPort.record(request.orderId(), "ORDER_REJECTED", validation.violations().toString());
    return OrderProcessingResult.rejected(request.orderId(), validation.violations());
}

Der Vorteil ist groß: Die fachliche Prüfung ist isoliert und testbar. Sie braucht keine Datenbank, kein Payment, keine Rechnung und keinen Broker. Dadurch wird die erste Verantwortung aus der Monster-Methode herausgelöst, ohne gleich die ganze Anwendung umzubauen.

Typische Fehler bei diesem Schritt:

  • Validierung ruft schon externe Services auf.
  • Validierung wirft technische Exceptions für normale Business-Ablehnungen.
  • Validierung verändert den Request.
  • Validierung enthält bereits Pricing-Regeln.
  • Tests prüfen nur den ersten Fehler und übersehen kombinierte Szenarien.

04. Pricing als Strategy schneiden

Preislogik wächst in echten Systemen fast immer: VIP-Rabatt, B2B-Volumenrabatt, Gutscheine, Kampagnen, Channel-Regeln, Partnerpreise, Steuerbasis, Rundung, Währungen. Wenn das alles in einer Methode bleibt, wird jede neue Regel gefährlich.

Kapitel verwendet deshalb DiscountPolicy als Strategy:

JAVA
public interface DiscountPolicy {
    Money discountFor(PricingContext context);
    String name();
}

Die einzelnen Regeln sind danach klein und gezielt testbar:

JAVA
public final class VipDiscountPolicy implements DiscountPolicy {
    @Override public Money discountFor(PricingContext context) {
        if (context.segment() != CustomerSegment.VIP) return Money.eur("0");
        return context.subtotal().multiply(new BigDecimal("0.10"));
    }
}

Das ist nicht nur ein Pattern um des Patterns willen. Die Strategy hilft, fachliche Änderungsgründe zu trennen. Eine neue Gutscheinregel ändert nicht die B2B-Regel. Eine neue VIP-Regel ändert nicht die Steuerlogik. In einer Code Review kann man eine Regel verstehen, ohne die ganze Order-Verarbeitung zu lesen.

Wichtig ist aber: Strategy kann auch übertrieben werden. Nicht jede if-Anweisung braucht ein eigenes Objekt. Der sinnvolle Einsatz beginnt dort, wo Regeln unabhängig wachsen, separat getestet werden müssen oder von unterschiedlichen Fachbereichen verantwortet werden.

05. Shipping und Tax als eigene Domain Services

Versand und Steuer sind typische Beispiele für Logik, die viele Eingaben braucht, aber nicht gut in ein einzelnes Entity-Objekt passt. Versand hängt an Land, Gewicht, Warenwert, digital/physisch und manchmal Kundensegment. Steuer hängt an Land, VAT-ID, B2B/B2C, Produktart und Compliance-Regeln.

Darum sind ShippingCalculator und TaxCalculator als Domain Services modelliert. Sie sind keine technischen Services. Sie kapseln fachliche Regeln.

JAVA
public Money calculate(OrderRequest request, Money afterDiscount) {
    int weight = request.lines().stream().mapToInt(OrderLine::totalWeight).sum();
    boolean onlyDigital = request.lines().stream().allMatch(OrderLine::digital);
    if (onlyDigital) return Money.eur("0");
    ...
}

Der Deep-Dive-Punkt ist: Eine Domain Service Klasse darf nicht zum neuen Sammelbecken werden. Sie braucht einen klaren Grund. ShippingCalculator darf Versand berechnen, aber nicht Payment aufrufen. TaxCalculator darf Steuer berechnen, aber nicht Rechnung speichern.

Produktionshinweis: Steuerlogik muss in echten Systemen versioniert werden. Eine Rechnung von 2024 darf nicht automatisch mit Regeln von 2026 neu interpretiert werden. Im Lab ist das vereinfacht, aber im Buch wird die Grenze sichtbar gemacht.

06. Ports fuer Nebenwirkungen einfuehren

Die Monster-Methode mischt Berechnung und Nebenwirkung. Das ist das eigentliche Risiko. Eine Preisberechnung kann man beliebig oft ausführen. Eine Payment-Autorisierung nicht. Eine Rechnungserzeugung vielleicht auch nicht. Audit muss zuverlässig passieren, aber soll den Kern nicht dominieren.

Darum werden externe Aktionen hinter Ports gelegt:

JAVA
public interface PaymentPort {
    PaymentDecision authorize(String orderId, String customerId, Money amount);
}

Der neue Application Service kennt nur den Port. Für Tests gibt es FakePaymentAdapter, FakeInvoiceAdapter und CollectingAuditAdapter. Damit kann man Verhalten prüfen, ohne echte Systeme zu berühren.

Das ist ein wichtiger Refactoring-Schritt: Man verbessert nicht sofort die Infrastruktur. Man trennt zuerst den Kern von der Infrastruktur. Später kann ein Spring Adapter, ein Jakarta Adapter oder ein Kafka Adapter folgen. Der Kern bleibt gleich.

Anti-Pattern: Direkt nach dem Extrahieren echte REST-Clients oder Repositorys in die neuen Services einbauen. Dann hat man nur die Monster-Methode verteilt, aber nicht entkoppelt.

07. Application Service als neuer Orchestrator

Der RefactoredOrderApplicationService ist absichtlich kein Ort fuer Detailregeln. Er orchestriert. Er sagt: validieren, Preis berechnen, Payment autorisieren, Invoice erzeugen, Audit schreiben. Dadurch wird der Use Case lesbar.

JAVA
ValidationResult validation = validationService.validate(request);
PriceBreakdown price = pricingEngine.calculate(request);
PaymentDecision payment = paymentPort.authorize(request.orderId(), request.customerId(), price.total());
String invoiceId = invoicePort.createInvoice(request.orderId(), price);

Guter Application-Service-Code liest sich fast wie ein fachlicher Ablauf. Schlechter Application-Service-Code enthält wieder alle Details. Die Grenze ist einfach: Wenn der Application Service weiß, wie Steuer in Deutschland berechnet wird, ist er zu tief. Wenn er weiß, dass nach erfolgreichem Payment eine Rechnung erzeugt wird, ist das richtig.

Dieses Muster ist besonders hilfreich beim späteren Umbau auf Spring Boot, Jakarta EE, Quarkus oder Micronaut. Der Framework-Controller ruft nur den Application Service auf. Der Kern bleibt frameworkarm.

08. Parity Tests: Legacy und Refactoring vergleichen

Ein gefährliches Missverständnis lautet: Wenn der neue Code sauberer aussieht, ist er richtig. Das stimmt nicht. Beim Refactoring ist richtig, was für definierte Szenarien das gleiche fachliche Verhalten zeigt oder bewusst dokumentiert abweicht.

Kapitel nutzt ParitySnapshot:

JAVA
public record ParitySnapshot(String scenarioId, String legacyResult, String refactoredResult) {
    public boolean equivalentEnough() {
        boolean legacyAccepted = legacyResult.startsWith("ACCEPTED");
        boolean refAccepted = refactoredResult.startsWith("true|");
        if (legacyAccepted != refAccepted) return false;
        if (legacyAccepted) return legacyResult.contains(scenarioId) && refactoredResult.contains(scenarioId);
        return true;
    }
}

Das ist bewusst nicht perfekt. In frühen Refactoring-Phasen ist eine weiche Äquivalenz oft sinnvoller als absolute Gleichheit. Absolute Gleichheit kann man später einführen, wenn Ausgaben normalisiert sind. Anfangs geht es um fachliche Entscheidungen: akzeptiert oder abgelehnt, richtige Order, richtige Nebenwirkungen.

Diese Tests sind der Brückenmechanismus zwischen Legacy-Sicherheit und neuer Architektur.

09. Architecture Boundary: Rueckfall verhindern

Refactoring scheitert oft nicht beim ersten Schnitt, sondern beim Rückfall. Ein Entwickler braucht schnell eine Information und greift wieder direkt auf die Legacy-Klasse zu. Nach einigen Wochen hängt der neue Code wieder am alten Kern.

Darum enthält das Lab einen kleinen Architekturcheck:

JAVA
long forbidden = Files.walk(root)
.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.getFileName().toString().contains("LegacyOrderProcessor"))
.filter(p -> Files.readString(p).contains("new LegacyOrderProcessor"))
.count();

In echten Projekten würde man dafür ArchUnit oder ähnliche Werkzeuge nutzen. Im JDK-only-Lab reicht ein einfacher Check, um das Prinzip zu zeigen: Architektur ist nicht nur ein Bild. Architektur braucht ausführbare Regeln.

Wichtige Regeln für diesen Refactoring-Schritt:

  • Neuer Application Service darf LegacyProcessor nicht aufrufen.
  • Domain Services dürfen keine Adapter kennen.
  • Pricing darf kein Payment kennen.
  • Validation darf keine Rechnung erzeugen.
  • Adapter dürfen Kernobjekte nutzen, aber Kern darf Adapter nicht kennen.

10. Uebungsaufgaben und Produktionscheckliste

Übungen:

  1. Füge eine neue Rabattregel BlackFridayDiscountPolicy hinzu, ohne PricingEngine grundlegend umzubauen.
  2. Schreibe einen Test, der Partner-Portal-Bestellungen mit Gutschein ablehnt.
  3. Ändere die Versandregel für die Schweiz und prüfe, welche Tests betroffen sind.
  4. Füge eine neue Steuerregel für digitale Produkte hinzu und entscheide, ob sie in TaxCalculator oder in eine Strategy gehört.
  5. Ergänze einen Parity Test, der Legacy- und Refactoring-Ergebnis stärker vergleicht.
  6. Erweitere den Architekturcheck, damit ShippingCalculator keinen PaymentPort importieren darf.

Produktionscheckliste:

  • Gibt es Golden Master Szenarien für die wichtigsten Umsatzfälle?
  • Sind alle Nebenwirkungen hinter Ports?
  • Sind externe Systeme in Tests durch deterministische Fakes ersetzt?
  • Sind neue fachliche Begriffe im Code sichtbar?
  • Gibt es eine dokumentierte Abweichungsliste zwischen Legacy und Refactoring?
  • Gibt es Metriken für alten und neuen Pfad, wenn beide parallel laufen?
  • Ist klar, wann der Legacy-Pfad abgeschaltet werden darf?

Kapitel ist damit nicht der Abschluss des Refactorings. Er ist der Schritt, in dem aus einer Monster-Methode ein lesbarer, testbarer Use Case wird. Kapitel kann darauf aufbauen und die Extraktion weiterführen: schrittweises Vorher/Nachher-Refactoring, Method Object, Feature Flags und Migrationspfad.

Code-Walkthrough: wichtigste Dateien

LegacyOrderProcessor.java

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
// Absichtlich unangenehmer Legacy-Code: mehrere Verantwortungen, Seiteneffekte und fachliche Regeln in einer Methode.
public final class LegacyOrderProcessor {
    public String process(OrderRequest request) {
        if (request.segment() == CustomerSegment.BLOCKED) return "REJECTED|CUSTOMER_BLOCKED";
        if (request.lines().isEmpty()) return "REJECTED|EMPTY_ORDER";
        if (request.segment() == CustomerSegment.B2B && request.shippingCountry() != Country.US && (request.vatId() == null || request.vatId().isBlank())) {
            return "REJECTED|VAT_ID_REQUIRED";
        }
    Money subtotal = Money.eur("0");
    int weight = 0;
    boolean onlyDigital = true;
    for (OrderLine line : request.lines()) {
        if (line.quantity() > 100) return "REJECTED|LINE_QTY_LIMIT";
        subtotal = subtotal.add(line.lineTotal());
        weight += line.totalWeight();
        onlyDigital = onlyDigital && line.digital();
    }
Money discount = Money.eur("0");
if (request.segment() == CustomerSegment.VIP) discount = discount.add(subtotal.multiply(new BigDecimal("0.10")));
if (request.couponCode() != null && request.channel() != OrderChannel.PARTNER_PORTAL) {
    if (request.couponCode().equals("SAVE5")) discount = discount.add(Money.eur("5"));
    if (request.couponCode().equals("WELCOME15")) discount = discount.add(subtotal.multiply(new BigDecimal("0.15")));
}
if (request.segment() == CustomerSegment.B2B && subtotal.amount().compareTo(new BigDecimal("1000.00")) >= 0) {
    discount = discount.add(subtotal.multiply(new BigDecimal("0.07")));
}
if (discount.greaterThan(subtotal)) discount = subtotal;
Money afterDiscount = subtotal.subtract(discount);
Money shipping = Money.eur("0");
if (!onlyDigital) {
    shipping = switch (request.shippingCountry()) {
        case AT, DE -> Money.eur("6.90");
        case CH -> Money.eur("14.90");
        case US -> Money.eur("29.90");
    };
if (afterDiscount.amount().compareTo(new BigDecimal("250.00")) >= 0 && request.shippingCountry() != Country.US) shipping = Money.eur("0");
if (weight > 10_000) shipping = shipping.add(Money.eur("12.00"));
}
Money taxableBase = afterDiscount.add(shipping);
Money tax;
if (request.segment() == CustomerSegment.B2B && request.vatId() != null && request.shippingCountry() != Country.AT) tax = Money.eur("0");
else {
    BigDecimal rate = switch (request.shippingCountry()) {
        case AT -> new BigDecimal("0.20");
        case DE -> new BigDecimal("0.19");
        case CH -> new BigDecimal("0.081");
        case US -> BigDecimal.ZERO;
    };
tax = taxableBase.multiply(rate);
}
Money total = taxableBase.add(tax);
if (total.amount().compareTo(new BigDecimal("2000.00")) > 0) return "REJECTED|LIMIT_EXCEEDED|" + total;
return "ACCEPTED|" + request.orderId() + "|" + total + "|INV-" + request.orderId();
}
}

OrderValidationService.java

JAVA
package com.example.legacy.Kapitel;
// Pattern: Service Layer - kapselt fachliche Validierung ausserhalb der Monster-Methode.
public final class OrderValidationService {
    public ValidationResult validate(OrderRequest request) {
        ValidationResult result = new ValidationResult();
        if (request.segment() == CustomerSegment.BLOCKED) result.add("CUSTOMER_BLOCKED", "blocked customers cannot order");
        if (request.lines().isEmpty()) result.add("EMPTY_ORDER", "order must contain at least one line");
        if (request.lines().size() > 50) result.add("TOO_MANY_LINES", "manual review required for more than 50 lines");
        if (request.channel() == OrderChannel.PARTNER_PORTAL && request.couponCode() != null) {
            result.add("PARTNER_COUPON_NOT_ALLOWED", "partner orders cannot use retail coupons");
        }
    if (request.segment() == CustomerSegment.B2B && request.shippingCountry() != Country.US && blank(request.vatId())) {
        result.add("VAT_ID_REQUIRED", "B2B orders in EU/CH require a VAT id");
    }
for (OrderLine line : request.lines()) {
    if (line.quantity() > 100) result.add("LINE_QTY_LIMIT", "line quantity too high for " + line.sku());
    if (!line.unitPrice().isPositive()) result.add("PRICE_NOT_POSITIVE", "unit price must be positive for " + line.sku());
}
return result;
}
private static boolean blank(String s) {
    return s == null || s.isBlank();
}
}

PricingEngine.java

JAVA
package com.example.legacy.Kapitel;
import java.util.List;
// Pattern: Composite Strategy - kombiniert mehrere Rabattstrategien kontrolliert.
public final class PricingEngine {
    private final List<DiscountPolicy> policies;
    private final ShippingCalculator shippingCalculator;
    private final TaxCalculator taxCalculator;
    public PricingEngine(List<DiscountPolicy> policies, ShippingCalculator shippingCalculator, TaxCalculator taxCalculator) {
        this.policies = List.copyOf(policies);
        this.shippingCalculator = shippingCalculator;
        this.taxCalculator = taxCalculator;
    }
public PriceBreakdown calculate(OrderRequest request) {
    Money subtotal = request.lines().stream().map(OrderLine::lineTotal).reduce(Money.eur("0"), Money::add);
    PricingContext context = new PricingContext(request.segment(), request.channel(), request.shippingCountry(), request.couponCode(), subtotal);
    Money discount = policies.stream().map(p -> p.discountFor(context)).reduce(Money.eur("0"), Money::add);
    if (discount.greaterThan(subtotal)) discount = subtotal;
    Money shipping = shippingCalculator.calculate(request, subtotal.subtract(discount));
    Money taxableBase = subtotal.subtract(discount).add(shipping);
    Money tax = taxCalculator.calculate(request, taxableBase);
    Money total = taxableBase.add(tax);
    return new PriceBreakdown(subtotal, discount, shipping, tax, total);
}
}

RefactoredOrderApplicationService.java

JAVA
package com.example.legacy.Kapitel;
// Pattern: Facade/Application Service - orchestriert extrahierte Verantwortungen ohne fachliche Details zu mischen.
public final class RefactoredOrderApplicationService {
    private final OrderValidationService validationService;
    private final PricingEngine pricingEngine;
    private final PaymentPort paymentPort;
    private final InvoicePort invoicePort;
    private final AuditPort auditPort;
    public RefactoredOrderApplicationService(OrderValidationService validationService, PricingEngine pricingEngine, PaymentPort paymentPort, InvoicePort invoicePort, AuditPort auditPort) {
        this.validationService = validationService;
        this.pricingEngine = pricingEngine;
        this.paymentPort = paymentPort;
        this.invoicePort = invoicePort;
        this.auditPort = auditPort;
    }
public OrderProcessingResult process(OrderRequest request) {
    ValidationResult validation = validationService.validate(request);
    if (!validation.ok()) {
        auditPort.record(request.orderId(), "ORDER_REJECTED", validation.violations().toString());
        return OrderProcessingResult.rejected(request.orderId(), validation.violations());
    }
PriceBreakdown price = pricingEngine.calculate(request);
PaymentDecision payment = paymentPort.authorize(request.orderId(), request.customerId(), price.total());
if (!payment.approved()) {
    auditPort.record(request.orderId(), "PAYMENT_DECLINED", payment.reason());
    return OrderProcessingResult.paymentDeclined(request.orderId(), price, payment.reason());
}
String invoiceId = invoicePort.createInvoice(request.orderId(), price);
auditPort.record(request.orderId(), "ORDER_ACCEPTED", invoiceId + " " + price.compact());
return OrderProcessingResult.accepted(request.orderId(), price, invoiceId);
}
}

ResponsibilityRefactoringTestRunner.java

JAVA
package com.example.legacy.Kapitel;
import java.util.ArrayList;
import java.util.List;
public final class ResponsibilityRefactoringTestRunner {
    public static void main(String[] args) {
        LegacyOrderProcessor legacy = new LegacyOrderProcessor();
        List<ParitySnapshot> snapshots = new ArrayList<>();
        for (OrderRequest request : OrderScenarioCatalog.scenarios()) {
            FakeInvoiceAdapter invoice = new FakeInvoiceAdapter();
            CollectingAuditAdapter audit = new CollectingAuditAdapter();
            RefactoredOrderApplicationService service = RefactoringFactory.create(invoice, audit);
            String legacyResult = legacy.process(request);
            String refactored = service.process(request).compact();
            ParitySnapshot snapshot = new ParitySnapshot(request.orderId(), legacyResult, refactored);
            snapshots.add(snapshot);
            if (!snapshot.equivalentEnough()) throw new AssertionError("parity failed: " + snapshot);
        }
    testValidationService();
    testPricingBreakdown();
    testShippingAndTax();
    System.out.println("Kapitel snapshots=" + snapshots.size());
}
private static void testValidationService() {
    ValidationResult result = new OrderValidationService().validate(OrderScenarioCatalog.blockedCustomer());
    if (result.ok()) throw new AssertionError("blocked customer must be rejected");
}
private static void testPricingBreakdown() {
    PricingEngine engine = new PricingEngine(java.util.List.of(new VipDiscountPolicy(), new CouponDiscountPolicy(), new B2BVolumeDiscountPolicy()), new ShippingCalculator(), new TaxCalculator());
    PriceBreakdown price = engine.calculate(OrderScenarioCatalog.webVipWithCoupon());
    if (!price.discount().isPositive()) throw new AssertionError("VIP/coupon discount expected");
    if (!price.total().isPositive()) throw new AssertionError("total expected");
}
private static void testShippingAndTax() {
    PricingEngine engine = new PricingEngine(java.util.List.of(), new ShippingCalculator(), new TaxCalculator());
    PriceBreakdown price = engine.calculate(OrderScenarioCatalog.heavySwissShipment());
    if (!price.tax().isPositive()) throw new AssertionError("CH tax expected");
    if (!price.total().isPositive()) throw new AssertionError("CH total expected");
}
}

Code-Labs zu diesem Kapitel

13. Method Object, Feature Flags und Parallelbetrieb

Kapitelkompass

Parallelbetrieb braucht messbare Parität

Feature Flags und Shadow Traffic reduzieren Cutover-Risiken, wenn Abweichungen erklärt und Zustände kontrolliert werden.

FlagAusgangspunktShadowEinordnenVergleichAbsichernCutoverErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Flags mit Lebenszyklus bauen
  • Shadow Reads nutzen
  • Parity messen
  • Rollback erhalten

Praxisfall

Alt- und Neusystem berechnen parallel denselben Auftrag, aber nur ein Pfad darf schreiben.

Entscheidung

Beobachtung zuerst, Teiltraffic danach, vollständiger Cutover erst bei stabilen Gates.

!

Typisches Risiko

Dauerhafte Flags vervielfachen Zustände und werden selbst zu Legacy.

Thema: Schrittweises Vorher/Nachher-Refactoring mit Method Object, Feature Flag und Parallelbetrieb.

1. Orientierung: Von getesteter Legacy zu kontrolliertem Parallelbetrieb

Kapitel vertieft den Schritt nach Characterization Tests und erster Verantwortungsextraktion. Jetzt geht es nicht mehr nur darum, eine Klasse schöner aussehen zu lassen. Es geht darum, ein produktionsnahes Refactoring so zu steuern, dass die Anwendung jederzeit auslieferbar bleibt.

Ein gefährliches Refactoring beginnt oft mit einem guten Wunsch: Die Monster-Methode soll verschwinden. Das Problem ist nicht dieser Wunsch, sondern die Reihenfolge. Wenn man zuerst die neue Zielarchitektur baut und danach hofft, dass alles gleich funktioniert, entsteht ein Big-Bang-Risiko. In Enterprise-Systemen sind fachliche Sonderfälle, Rundungsregeln, Nebenwirkungen, Mandantenregeln und historische Daten oft nur im alten Code zuverlässig abgebildet.

Das Ziel von Kapitel ist deshalb eine Pipeline:

JAVA
package com.example.enterprise.Kapitel;
// Pattern: Branch by Abstraction + Feature Toggle - alter und neuer Pfad sind hinter einem stabilen Einstiegspunkt steuerbar.
public final class RefactoringRouter {
    private final LegacyOrderProcessor legacy;
    private final RefactoredOrderProcessor modern;
    private final FeatureToggleService toggles;
    private final ParityReporter parityReporter;
    public RefactoringRouter(LegacyOrderProcessor legacy, RefactoredOrderProcessor modern,
    FeatureToggleService toggles, ParityReporter parityReporter) {
        this.legacy = legacy;
        this.modern = modern;
        this.toggles = toggles;
        this.parityReporter = parityReporter;
    }
public OrderResult process(OrderRequest request) {
    ProcessingMode mode = toggles.modeFor(request.customer());
    return switch (mode) {
        case LEGACY -> legacy.process(request);
        case SHADOW -> shadow(request);
        case NEW_WITH_FALLBACK -> newWithFallback(request);
        case NEW -> modern.process(request);
    };
}
private OrderResult shadow(OrderRequest request) {
    OrderResult legacyResult = legacy.process(request);
    try {
        OrderResult modernResult = modern.process(request);
        parityReporter.compare(request, legacyResult, modernResult);
    }
catch (RuntimeException ex) {
    parityReporter.compare(request, legacyResult, OrderResult.rejected("modern exception: " + ex.getClass().getSimpleName()));
}
return legacyResult;
}
private OrderResult newWithFallback(OrderRequest request) {
    try {
        return modern.process(request);
    }
catch (RuntimeException ex) {
    return legacy.process(request);
}
}
}

Wichtig ist: Der Router ist nicht “nur Technik”. Er ist die Sicherheitsarchitektur des Refactorings. Er entscheidet, ob Legacy, Shadow, New-with-Fallback oder New läuft. Dadurch kann man die fachliche Migration betreiben, messen und zurückrollen.

Typischer Fehler

Viele Teams ersetzen eine Monster-Methode direkt durch mehrere Services. Das sieht nach Clean Code aus, aber ohne Shadow-Vergleich weiß niemand, ob die alten Sonderfälle erhalten geblieben sind.

Bessere Reihenfolge

  1. Verhalten einfrieren.
  2. Method Object einführen.
  3. Verantwortungen extrahieren.
  4. Legacy und Neu parallel ausführen.
  5. Unterschiede klassifizieren.
  6. Rollout über Feature Flags steuern.

2. Method Object: Monster-Methode zerlegen ohne sofort alles neu zu designen

Das Method-Object-Pattern ist im Refactoring sehr wertvoll, weil es einen Zwischenzustand erlaubt. Eine lange Methode hat oft viele lokale Variablen. Wenn man einfach Methoden extrahiert, muss man ständig Parameter herumreichen. Das führt zu Methoden mit zehn Parametern oder zu statischem Hilfscode. Ein Method Object macht den Ablauf explizit: Der Workflow wird zu einem Objekt, die Zwischenergebnisse werden Felder, und die Teilschritte bekommen fachliche Namen.

Die Legacy-Methode mischt Validierung, Preisberechnung, Versand, Payment, Persistenz und Outbox:

JAVA
package com.example.enterprise.Kapitel;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
// Legacy-Beispiel: bewusst gemischte Verantwortungen. Nicht als Zielarchitektur verwenden.
public final class LegacyOrderProcessor {
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    public LegacyOrderProcessor(PaymentPort payment, OrderRepository repository, OutboxPort outbox) {
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult process(OrderRequest request) {
    List<String> messages = new ArrayList<>();
    if (request.customer().blocked()) return OrderResult.rejected("customer blocked");
    if (request.lines().isEmpty()) return OrderResult.rejected("empty order");
    Money net = Money.eur("0");
    Money tax = Money.eur("0");
    for (OrderLine line : request.lines()) {
        if (line.sku().startsWith("DISCONTINUED")) return OrderResult.rejected("discontinued sku " + line.sku());
        Money lineNet = line.netTotal();
        net = net.add(lineNet);
        tax = tax.add(lineNet.multiply(line.taxRate()));
    }
if (request.customer().premium()) {
    net = net.subtract(net.multiply(new BigDecimal("0.05")));
    messages.add("premium discount applied");
}
Money shipping = switch (request.shippingMethod()) {
    case "EXPRESS" -> Money.eur("19.90");
    case "PICKUP" -> Money.eur("0.00");
    default -> request.customer().premium() ? Money.eur("0.00") : Money.eur("5.90");
};
Money grand = net.add(tax).add(shipping);
PaymentReceipt receipt = payment.authorize(request.customer().customerId(), grand);
if (!receipt.approved()) return OrderResult.rejected("payment declined: " + receipt.reason());
String orderId = "ORD-" + request.requestId();
OrderResult result = new OrderResult(OrderDecision.ACCEPTED, orderId, net, tax, shipping, grand, messages);
repository.save(orderId, request, result);
outbox.append(orderId, "OrderAccepted", "grandTotal=" + grand.amount());
return result;
}
}

Das Method Object zeigt dieselbe Verarbeitung, aber in benannten Schritten:

JAVA
package com.example.enterprise.Kapitel;
import java.util.ArrayList;
import java.util.List;
// Pattern: Method Object - zerlegt eine lange Methode in einen zustandsbehafteten, benannten Workflow.
public final class OrderProcessingWorkflow {
    private final ValidationService validation;
    private final PricingService pricing;
    private final TaxService taxService;
    private final ShippingService shippingService;
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    private OrderRequest request;
    private Money net;
    private Money tax;
    private Money shipping;
    private Money grand;
    private final List<String> messages = new ArrayList<>();
    public OrderProcessingWorkflow(ValidationService validation, PricingService pricing, TaxService taxService,
    ShippingService shippingService, PaymentPort payment, OrderRepository repository,
    OutboxPort outbox) {
        this.validation = validation;
        this.pricing = pricing;
        this.taxService = taxService;
        this.shippingService = shippingService;
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult execute(OrderRequest request) {
    this.request = request;
    var error = validation.validate(request);
    if (error.isPresent()) return OrderResult.rejected(error.get());
    calculateTotals();
    PaymentReceipt receipt = payment.authorize(request.customer().customerId(), grand);
    if (!receipt.approved()) return OrderResult.rejected("payment declined: " + receipt.reason());
    return persistAndPublish();
}
private void calculateTotals() {
    net = pricing.netTotal(request);
    tax = taxService.taxTotal(request);
    shipping = shippingService.shipping(request);
    if (request.customer().premium()) messages.add("premium discount applied");
    grand = net.add(tax).add(shipping);
}
private OrderResult persistAndPublish() {
    String orderId = "ORD-" + request.requestId();
    OrderResult result = new OrderResult(OrderDecision.ACCEPTED, orderId, net, tax, shipping, grand, List.copyOf(messages));
    repository.save(orderId, request, result);
    outbox.append(orderId, "OrderAccepted", "grandTotal=" + grand.amount());
    return result;
}
}

Warum das tiefer ist als Extract Method

Extract Method ist eine Operation. Method Object ist eine Refactoring-Strategie. Es erlaubt, Zwischenergebnisse bewusst zu halten, ohne globale Variablen zu verwenden. Es macht den Ablauf testbar und schafft Stellen, an denen später echte Domain Services oder Ports eingeführt werden können.

Wann Method Object nicht reicht

Wenn das Method Object selbst wieder groß wird, ist es nicht das Endziel. Es ist dann eine sichere Zwischenstation. Der nächste Schritt ist, fachliche Dienste zu extrahieren: ValidationService, PricingService, TaxService, ShippingService.

3. Feature Flags: Refactoring als Betriebsentscheidung

Ein Feature Flag ist hier nicht nur ein Produktfeature-Schalter. Es ist ein Sicherheitsmechanismus für technische Modernisierung. Es erlaubt, neuen Code neben altem Code zu betreiben, ohne sofort alle Nutzer umzustellen.

Die vier Modi in diesem Kapitel sind bewusst gewählt:

Modus Antwort kommt von Neuer Code läuft? Zweck
LEGACY Legacy nein Ausgangszustand, stabiler Betrieb
SHADOW Legacy ja Parity sammeln ohne Kundenauswirkung
NEW_WITH_FALLBACK Neu nur bei Fehler Legacy vorsichtiger Cutover
NEW Neu ja Zielbetrieb
JAVA
package com.example.enterprise.Kapitel;
import java.util.HashMap;
import java.util.Map;
public final class InMemoryFeatureToggleService implements FeatureToggleService {
    private ProcessingMode defaultMode = ProcessingMode.LEGACY;
    private final Map<String, ProcessingMode> customerModes = new HashMap<>();
    public void setDefaultMode(ProcessingMode defaultMode) {
        this.defaultMode = defaultMode;
    }
public void setModeForCustomer(String customerId, ProcessingMode mode) {
    customerModes.put(customerId, mode);
}
@Override public ProcessingMode modeFor(CustomerProfile customer) {
    return customerModes.getOrDefault(customer.customerId(), defaultMode);
}
}

Warum Shadow Mode so wichtig ist

Im Shadow Mode ist der neue Code nicht kundensichtbar. Er darf aber die gleichen Eingaben bekommen und sein Ergebnis kann mit dem Legacy-Ergebnis verglichen werden. So findet man Regelabweichungen, bevor sie produktiv werden.

Gefahr

Shadow Mode darf keine echten doppelten Seiteneffekte auslösen. Payment, E-Mail, externe Buchung und echte Outbox müssen im Shadow Mode kontrolliert oder simuliert werden. Sonst erzeugt der Vergleich selbst Produktionsfehler.

4. Parallelbetrieb: Legacy und Neu vergleichen

Parallelbetrieb bedeutet nicht blind beide Pfade laufen lassen. Man braucht eine klare Diff-Klassifikation.

JAVA
package com.example.enterprise.Kapitel;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
// Pattern: Observer/Reporter - Shadow-Ergebnisse werden gemeldet, ohne den Use Case zu blockieren.
public final class ParityReporter {
    private final List<ParityEvent> events = new ArrayList<>();
    public void compare(OrderRequest request, OrderResult legacy, OrderResult modern) {
        boolean same = legacy.decision() == modern.decision()
        && Objects.equals(legacy.grandTotal(), modern.grandTotal())
        && Objects.equals(legacy.messages(), modern.messages());
        events.add(new ParityEvent(request.requestId(), same, same ? "EXACT_MATCH" : "BUSINESS_DIFFERENCE",
        "legacy=" + legacy + " modern=" + modern));
    }
public List<ParityEvent> events() {
    return List.copyOf(events);
}
}

Ein Vergleich “Objekt gleich oder nicht” ist zu grob. In echten Systemen gibt es viele Arten von Abweichungen:

  • Rundung unterscheidet sich um einen Cent.
  • Text in Fehlermeldung ist anders, aber Fehlercode identisch.
  • Reihenfolge von Events unterscheidet sich, fachlich aber nicht relevant.
  • Steuerberechnung ist anders, fachlich kritisch.
  • Legacy akzeptiert, neuer Code lehnt ab.
  • Neuer Code wirft technische Exception.

Deep-Dive-Regel

Nicht jede Abweichung ist ein Fehler. Aber jede Abweichung braucht eine Klassifikation. Genau hier scheitern viele Refactorings: Es wird zu früh auf “gleich” oder “kaputt” reduziert.

Produktionshinweis

Parity-Events dürfen keine sensiblen Daten ungefiltert loggen. Kundendaten, Zahlungsdaten und personenbezogene Felder müssen maskiert oder nur als technische Referenzen gespeichert werden.

5. Verantwortung extrahieren: Validation, Pricing, Shipping, Tax

Die Verantwortungsextraktion aus Kapitel wird in Kapitel unter Rollout-Bedingungen betrachtet. Extraktion allein ist noch kein Produktionsplan. Die Services müssen in Parity-Tests gegen Legacy bestehen.

JAVA
package com.example.enterprise.Kapitel;
import java.util.Optional;
// Pattern: Extracted Domain Service - Validierung wird fachlich benannt und testbar.
public final class ValidationService {
    public Optional<String> validate(OrderRequest request) {
        if (request.customer().blocked()) return Optional.of("customer blocked");
        if (request.lines().isEmpty()) return Optional.of("empty order");
        return request.lines().stream()
        .filter(line -> line.sku().startsWith("DISCONTINUED"))
        .map(line -> "discontinued sku " + line.sku())
        .findFirst();
    }
}
JAVA
package com.example.enterprise.Kapitel;
import java.math.BigDecimal;
// Pattern: Strategy-Kandidat - Preisregeln sind der erste Schritt zu austauschbaren Preisstrategien.
public final class PricingService {
    public Money netTotal(OrderRequest request) {
        Money net = Money.eur("0");
        for (OrderLine line : request.lines()) net = net.add(line.netTotal());
        if (request.customer().premium()) net = net.subtract(net.multiply(new BigDecimal("0.05")));
        return net;
    }
}
JAVA
package com.example.enterprise.Kapitel;
public final class ShippingService {
    public Money shipping(OrderRequest request) {
        return switch (request.shippingMethod()) {
            case "EXPRESS" -> Money.eur("19.90");
            case "PICKUP" -> Money.eur("0.00");
            default -> request.customer().premium() ? Money.eur("0.00") : Money.eur("5.90");
        };
}
}

Fachliche Tiefe

Validation entscheidet nicht nur über Eingabefehler. In Enterprise-Systemen vermischt sie oft Compliance, Kundensperren, Produktstatus, Vertragsregeln und Mandantenlogik. Deshalb ist es riskant, sie zu früh als simple Bean-Validation zu behandeln.

Pricing ist ebenfalls nicht nur “Summe der Positionen”. Rabatte, Vertragskonditionen, Staffelpreise, Aktionscodes, Rundung und Steuerbasis können historische Sonderfälle haben. Deshalb wird Preislogik zuerst gegen Legacy verglichen und erst danach schöner modelliert.

Shipping und Tax sind gute Beispiele für scheinbar kleine Regeln, die produktiv teuer werden können. Versandkosten können Kundenkommunikation beeinflussen; Steuerfehler können rechtliche Folgen haben.

6. Branch by Abstraction: stabile Einstiegspunkte statt Big Bang

Branch by Abstraction bedeutet: Man schafft eine Abstraktion, hinter der alter und neuer Code austauschbar werden. Der Client nutzt nur den stabilen Einstiegspunkt. In diesem Kapitel ist das der RefactoringRouter.

JAVA
package com.example.enterprise.Kapitel;
// Pattern: Feature Toggle - Rollout-Entscheidung ist konfigurierbar und nicht im Fachcode versteckt.
public interface FeatureToggleService {
    ProcessingMode modeFor(CustomerProfile customer);
}
JAVA
package com.example.enterprise.Kapitel;
// Pattern: Application Service - orchestriert den Use Case, ohne alle Fachregeln selbst zu besitzen.
public final class RefactoredOrderProcessor {
    private final ValidationService validation;
    private final PricingService pricing;
    private final TaxService taxService;
    private final ShippingService shippingService;
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    public RefactoredOrderProcessor(ValidationService validation, PricingService pricing, TaxService taxService,
    ShippingService shippingService, PaymentPort payment, OrderRepository repository,
    OutboxPort outbox) {
        this.validation = validation;
        this.pricing = pricing;
        this.taxService = taxService;
        this.shippingService = shippingService;
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult process(OrderRequest request) {
    return new OrderProcessingWorkflow(validation, pricing, taxService, shippingService, payment, repository, outbox).execute(request);
}
}

Warum nicht einfach Git-Branch?

Ein langer Git-Branch verschiebt Risiko in die Zukunft. Branch by Abstraction hält den Hauptzweig lauffähig und erlaubt kleine produktionsnahe Schritte. Das ist bei großen Enterprise-Systemen oft wichtiger als perfektes lokales Design.

Typischer Fehler

Die Abstraktion wird zu früh fachlich idealisiert. Dann passt Legacy nicht hinein, und Teams beginnen, Adapter voller Sonderlogik zu schreiben. Besser: Die erste Abstraktion ist pragmatisch und stabil. Danach wird sie schrittweise verbessert.

7. Tests: Parity, Characterization und Architekturregeln

Kapitel nutzt Tests nicht nur als Korrektheitsprüfung, sondern als Steuerungsinstrument. Characterization Tests sichern Legacy-Verhalten. Parity Tests vergleichen alt und neu. Architekturregeln verhindern, dass der neue Pfad wieder zur Monster-Methode wird.

JAVA
package com.example.enterprise.Kapitel;
public final class Kapitel {
    public static void main(String[] args) {
        shouldKeepParityInShadowMode();
        shouldRejectBlockedCustomerInBothPaths();
        shouldRouteToNewPathWhenEnabled();
        System.out.println("Kapitel");
    }
private static void shouldKeepParityInShadowMode() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.SHADOW);
    OrderResult result = fixture.router.process(TestData.standardOrder("REQ-SHADOW"));
    assertTrue(result.accepted(), "legacy result should be accepted");
    assertEquals(1, fixture.reporter.events().size(), "shadow parity event expected");
    assertTrue(fixture.reporter.events().get(0).match(), "legacy and modern should match");
}
private static void shouldRejectBlockedCustomerInBothPaths() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.SHADOW);
    OrderResult result = fixture.router.process(TestData.blockedOrder());
    assertEquals(OrderDecision.REJECTED, result.decision(), "blocked customer rejected");
    assertTrue(fixture.reporter.events().get(0).match(), "blocked customer parity");
}
private static void shouldRouteToNewPathWhenEnabled() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.NEW);
    OrderResult result = fixture.router.process(TestData.standardOrder("REQ-NEW"));
    assertTrue(result.accepted(), "new path accepted");
    assertEquals(0, fixture.reporter.events().size(), "new mode does not shadow compare");
}
private static void assertTrue(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
private static void assertEquals(Object expected, Object actual, String message) {
    if (!java.util.Objects.equals(expected, actual)) throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
}
private record TestFixture(RefactoringRouter router, InMemoryFeatureToggleService toggles, ParityReporter reporter) {
    static TestFixture create() {
        InMemoryOutbox outbox = new InMemoryOutbox();
        InMemoryOrderRepository repository = new InMemoryOrderRepository();
        PaymentPort payment = new FakePaymentAdapter();
        LegacyOrderProcessor legacy = new LegacyOrderProcessor(payment, repository, outbox);
        RefactoredOrderProcessor modern = new RefactoredOrderProcessor(new ValidationService(), new PricingService(), new TaxService(), new ShippingService(), payment, repository, outbox);
        InMemoryFeatureToggleService toggles = new InMemoryFeatureToggleService();
        ParityReporter reporter = new ParityReporter();
        return new TestFixture(new RefactoringRouter(legacy, modern, toggles, reporter), toggles, reporter);
    }
}
}

Was diese Tests abdecken

  • Shadow Mode gibt Legacy-Ergebnis zurück.
  • Neuer Pfad läuft parallel.
  • Parity Event wird geschrieben.
  • Blockierte Kunden werden in beiden Pfaden gleich behandelt.
  • NEW Mode läuft ohne Shadow-Vergleich.

Was in echten Projekten zusätzlich nötig ist

  • Golden-Master-Snapshots für hunderte historische Bestellungen.
  • Parity-Statistik pro Mandant und Produktgruppe.
  • Toleranzregeln für Rundung und technische Metadaten.
  • Tests gegen echte anonymisierte Datenbestände.
  • Architekturtests: neuer Code darf nicht direkt auf Legacy-DAO zugreifen.

8. Cutover und Rollback: Refactoring als kontrollierter Release

Ein Refactoring ist erst dann enterprise-tauglich, wenn der Cutover geplant ist. Das Ziel ist nicht, “neuen Code fertig” zu haben. Das Ziel ist, risikoarm von Legacy auf Neu umzuschalten.

Rollout-Stufen

  1. LEGACY: Ausgangszustand.
  2. SHADOW: Parity sammeln.
  3. NEW_WITH_FALLBACK: neuer Code liefert Antwort, Legacy bleibt Rettungspfad.
  4. NEW: neuer Pfad ist primär.
  5. Legacy-Removal: erst nach Beobachtungsfenster.

Rollback-Regel

Rollback muss vor dem Rollout definiert sein. Wenn man erst während eines Incidents überlegt, wie man zurückschaltet, ist das Feature Flag nur Kosmetik.

Metriken

  • Parity-Match-Rate.
  • Anzahl technischer Exceptions im neuen Pfad.
  • Latenzvergleich Legacy vs Neu.
  • Anzahl Fallbacks.
  • fachliche Ablehnungsquote.
  • Anzahl Outbox-Events pro akzeptierter Bestellung.

9. Anti-Patterns bei Refactoring-Rollouts

Anti-Pattern 1: Big-Bang-Cleanup

Alles wird auf einmal schöner gemacht. Danach ist unklar, welche Änderung einen Fehler verursacht hat.

Anti-Pattern 2: Shadow Mode mit echten Seiteneffekten

Der neue Pfad sendet echte E-Mails, bucht echtes Payment oder erzeugt echte externe Tickets. Das macht aus einem Vergleich einen Produktionsfehler.

Anti-Pattern 3: Feature Flag ohne Besitzer

Niemand weiß, wann ein Flag entfernt wird. Nach sechs Monaten ist das System komplexer als vorher.

Anti-Pattern 4: Parity-Diffs ignorieren

Das Team sammelt Unterschiede, klassifiziert sie aber nicht. Shadow Mode wird dann zur Beruhigung, nicht zur Qualitätssicherung.

Anti-Pattern 5: Legacy zu früh löschen

Der neue Code läuft einen Tag stabil und Legacy wird entfernt. Am Monatsende taucht ein Sonderfall auf, der nur im Legacy-Code korrekt war.

10. Produktionscheckliste Kapitel

Vor dem Umschalten auf NEW_WITH_FALLBACK:

  • Characterization Tests grün.
  • Parity-Diffs klassifiziert.
  • Technische Exceptions im neuen Pfad unter definierter Schwelle.
  • Shadow Mode erzeugt keine doppelten externen Seiteneffekte.
  • Feature-Flag-Änderungen sind auditierbar.
  • Rollback-Schritt ist dokumentiert.
  • Support kennt neue Fehlermeldungen.
  • Monitoring zeigt Legacy/New/Fallback getrennt.
  • Datenbank- und Outbox-Nebenwirkungen sind geprüft.
  • Legacy-Removal-Termin ist als eigener Schritt geplant.

Lernaufgabe

Erweitere das Code-Lab um eine bewusste Abweichung in der Steuerberechnung. Lasse Shadow Mode laufen, klassifiziere die Abweichung als BUSINESS_DIFFERENCE, dokumentiere eine ADR und entscheide, ob die neue Regel korrekt ist oder der Legacy-Fall übernommen werden muss.

Code-Labs zu diesem Kapitel

14. Legacy-Abbau, Cutover und Cleanup

Kapitelkompass

Abschalten ist eine eigene Produktionsphase

Legacy darf erst verschwinden, wenn Traffic, Daten, Betrieb und Rollback gegen explizite Gates geprüft wurden.

GateAusgangspunktCutoverEinordnenBeobachtenAbsichernEntfernenErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Cutover-Gates definieren
  • Resttraffic erkennen
  • Daten verifizieren
  • Cleanup reversibel planen

Praxisfall

Das Neusystem bedient 100 Prozent Traffic, doch ein Batchjob liest noch aus der alten Datenbank.

Entscheidung

Code, Daten und Infrastruktur getrennt abbauen und jede Stufe mit Beobachtungszeit versehen.

!

Typisches Risiko

Zu frühes Entfernen zerstört Rollback, Audit-Spuren oder versteckte Verbraucher.

Schnelle Einordnung
SignalLesartNächster Schritt
Traffic GateKein relevanter Legacy-TrafficNachweis über Metriken und bekannte Verbraucher
Data GateMigration vollständig und konsistentCounts, Checksummen und Stichproben vergleichen
Rollback GateRückweg getestet und befristetVerantwortung, Zeitfenster und Datenrichtung dokumentieren

Legacy-Abbau, Strangler-Schlussphase und Cleanup

Kapitel schliesst die Legacy-Refactoring-Vertiefung ab: alte Pfade entfernen, Flags bereinigen, Daten-/Schema-Cleanup planen und den finalen Cutover pruefbar machen.

1. Warum Legacy-Abbau ein eigener Deep Dive ist

1.1 Das eigentliche Risiko liegt am Schluss

Viele Modernisierungen wirken erfolgreich, solange alter und neuer Pfad parallel existieren. Der gefaehrliche Teil beginnt erst danach: Feature Flags bleiben liegen, alte Tabellen werden weiter beschrieben, Support-Tools nutzen heimlich Legacy-Endpunkte, Batchjobs laufen nachts weiter, und nach drei Monaten weiss niemand mehr, welche Pfade wirklich produktiv sind. Kapitel behandelt deshalb die Schlussphase als eigenes Architekturthema und nicht als Aufraeumarbeit nebenbei.

1.2 Warum Loeschen fachlich schwieriger ist als Schreiben

Neuen Code zu schreiben erzeugt Optionen. Alten Code zu loeschen entfernt Optionen. Deshalb braucht die Schlussphase belastbare Nachweise: fachliche Parity, keine Legacy-Nutzung, klare Datenmigration, betriebliche Readiness und eine Entscheidung, wann ein Rollback nicht mehr realistisch ist. In Enterprise-Systemen ist diese Entscheidung oft wichtiger als die technische Pull Request Groesse.

1.3 Zielbild

Am Ende soll der neue Pfad nicht nur funktionieren. Der alte Pfad soll wirklich weg sein: keine Flags, keine toten Klassen, keine alten Jobs, keine verwaisten Tabellen, keine falschen Runbooks und keine Dashboards, die noch Legacy-Metriken als aktive Produktionssignale darstellen.

2. Strangler-Schlussphase verstehen

2.1 Von Shadow Mode zu Hard Cutover

Shadow Mode beantwortet die Frage: Kann der neue Pfad dasselbe Ergebnis liefern? Hard Cutover beantwortet eine andere Frage: Kann der neue Pfad allein betrieben werden? Diese Unterscheidung ist wichtig. Ein System kann fachlich korrekt sein und trotzdem nicht cutover-bereit, wenn Monitoring, Support, Rollback oder Datenmigration fehlen.

2.2 Vier Phasen

Die Schlussphase besteht aus Soft Cutover, Beobachtung, Hard Cutover und Cleanup. Soft Cutover routet produktiv auf den neuen Pfad, laesst Legacy aber als Sicherheitsnetz bestehen. Die Beobachtungsphase sammelt Nachweise. Hard Cutover macht den neuen Pfad verbindlich. Cleanup entfernt dann bewusst und kontrolliert die Altlasten.

2.3 Diagramm

Strangler Schlussphase

3. Cutover-Gates statt Bauchgefuehl

3.1 Parity Gate

Parity bedeutet nicht, dass jeder Log-Eintrag gleich ist. Parity bedeutet, dass fachlich relevante Ergebnisse innerhalb definierter Toleranzen gleich sind. Fuer Bestellungen koennen das Bruttobetrag, Steuerland, Rabattentscheidung, Rechnungsstatus und erzeugte Events sein. Technische Unterschiede wie andere interne Decision-Labels koennen erlaubt sein, solange sie keine fachliche Auswirkung haben.

3.2 Traffic Gate

Legacy-Traffic muss ueber ein definiertes Beobachtungsfenster null sein oder fachlich erklaerbar bleiben. Ein einzelner Support-Job, der noch Legacy liest, reicht aus, um Cleanup zu blockieren. Sonst loescht man Pfade, die im Normalbetrieb nicht sichtbar sind, aber in Sonderfaellen gebraucht werden.

3.3 Data Gate

Datenmigration ist nicht nur Zeilenanzahl. Wichtig sind referenzielle Konsistenz, fachliche Transformation, historische Sonderfaelle, Auditanforderungen und Backups. Alte Daten duerfen erst entfernt werden, wenn klar ist, ob sie fuer Reports, Steuerpruefung oder Support noch gebraucht werden.

3.4 Diagramm

Cutover Gates

4. Feature-Flag-Cleanup

4.1 Flags sind technische Schulden

Feature Flags sind in der Migration hilfreich, aber gefaehrlich, wenn sie dauerhaft bleiben. Jeder Flag erzeugt Kombinationszustaende: Legacy an, Legacy aus, Shadow an, Fallback an, Cutover an. Je mehr Flags bleiben, desto schwerer wird Testing und desto unsicherer wird Support.

4.2 Entfernen ist ein Prozess

Ein Flag darf erst entfernt werden, wenn Code, Tests, Dokumentation, Dashboards, Runbooks und Deployment-Parameter keine Referenz mehr haben. Ein reiner Code-Search reicht nicht immer, weil Flags auch in Helm Values, OpenShift ConfigMaps, Jenkins-Parametern oder Support-Skripten stehen koennen.

4.3 Codebeispiel

JAVA
var refs = new FlagReferenceScanner().findReferences(
"USE_NEW_ORDER_ENGINE",
Map.of("OrderFacade.java", "if (USE_NEW_ORDER_ENGINE) route();"));
var decision = new RemovalGuard().canRemove(plan, gates, refs);
// allowed == false, solange Referenzen existieren

5. Alte Codepfade entfernen

5.1 Nicht zuerst die Monsterklasse loeschen

Die groesste Legacy-Klasse zu loeschen ist psychologisch attraktiv, aber oft falsch. Zuerst muessen Aufrufer, Routing, Fallbacks, Batchjobs und Support-Tools entfernt oder umgestellt werden. Danach wird der alte Pfad klein, isoliert und erst dann geloescht.

5.2 Reihenfolge

Eine sichere Reihenfolge ist: keine neuen Aufrufe erlauben, Fallback deaktivieren, Legacy read-only setzen, Beobachtungsfenster abwarten, Architekturregel aktivieren, Klasse entfernen, Testdaten bereinigen, Dokumentation aktualisieren.

5.3 Diagramm

Cleanup Dependencies

6. Daten- und Schema-Cleanup

6.1 Warum Daten gefaehrlicher als Code sind

Code kann man aus Git wiederherstellen. Datenverlust ist schwieriger. Alte Tabellen, Spalten und Auditdaten duerfen nicht nur aus technischer Sicht betrachtet werden. Man muss Aufbewahrungspflichten, Reporting, Supportfaelle und historische Rekonstruktion pruefen.

6.2 Soft Delete fuer Schemas

In vielen Enterprise-Systemen ist ein Schema-Cleanup mehrstufig: erst Schreibzugriff stoppen, dann Lesepfade entfernen, dann Tabelle archivieren, dann Retention abwarten, dann Drop planen. Direktes Droppen im gleichen Release wie Hard Cutover ist selten eine gute Idee.

6.3 Codebeispiel

JAVA
var report = new DataMigrationVerifier().verify(legacyRows, targetRows);
if (!report.ready()) {
    throw new IllegalStateException("Migration blockiert: " + report.problems());
}

7. Rollback-Fenster und Irreversibilitaet

7.1 Rollback ist kein Gefuehl

Ein Rollback ist nur real, wenn Datenmodell, Codeversion, Broker-Events, Cache-Zustaende und externe Systeme ihn zulassen. Nach Hard Cutover kann ein Rollback fachlich unmoeglich werden, obwohl technisch ein altes Artefakt noch deploybar waere.

7.2 Irreversible Schritte trennen

Alte Datenbankobjekte, irreversible Datenmigrationen und Entfernen zentraler Fallback-Pfade sollten getrennte Releases sein. Dadurch kann man Fehler isolieren und vermeidet, dass ein Problem sofort mehrere Wiederherstellungsebenen betrifft.

7.3 Diagramm

Rollback Window

8. Betrieb und Support nach dem Cutover

8.1 Neue Dashboards muessen alt ersetzen

Es reicht nicht, Legacy-Dashboards zu entfernen. Neue Dashboards muessen die Fragen beantworten, die Betrieb und Support wirklich stellen: Wie viele Orders laufen? Wie viele Fehler? Welche Kundengruppen? Welche Latenz? Welche fachlichen Ablehnungen? Wo ist ein Retry sinnvoll?

8.2 Support braucht neue Diagnosepfade

Wenn Support bisher in Legacy-Tabellen oder Logs gesucht hat, muss es eine neue Diagnose geben. Sonst wird das Zielsystem zwar technisch sauber, aber organisatorisch nicht betreibbar.

8.3 Audit Trail

Cutover-Entscheidungen muessen spaeter erklaerbar sein: Wer hat umgeschaltet? Welche Gates waren gruen? Welche Diffs waren akzeptiert? Wann wurde Rollback geschlossen? Welche Daten wurden archiviert?

9. Java-Code-Walkthrough

9.1 StranglerOrderFacade

Die StranglerOrderFacade kapselt Routing und Parallelbetrieb. Aufrufer kennen nur process. Damit bleibt die Migration intern steuerbar, ohne jeden Controller, Batchjob oder Consumer mehrfach anzupassen.

9.2 CutoverGateEvaluator

Der CutoverGateEvaluator macht die Cutover-Entscheidung testbar. Das ist wichtig, weil Migrationen sonst oft durch Meeting-Entscheidungen und Bauchgefuehl gesteuert werden. Gate-Logik im Code zwingt zu klaren Kriterien.

9.3 RemovalGuard

Der RemovalGuard verhindert Entfernen trotz Restreferenzen oder nicht bestandener Gates. Das ist ein bewusstes Guard/Policy-Object: Cleanup ist nicht nur ein rm -rf legacy, sondern ein kontrollierter Architekturakt.

10. Tests fuer die Schlussphase

10.1 Was getestet wird

Kapitel testet nicht nur Business-Ergebnisse. Er testet auch Migrationsregeln: Hard Cutover routet nur noch neu, Flag-Referenzen blockieren Cleanup, fehlende Daten blockieren Migration, zu wenige Parity-Samples blockieren Cutover und irreversible Cleanup-Schritte werden erkannt.

10.2 Warum diese Tests wichtig sind

Normale Unit Tests sagen, ob der neue Code funktioniert. Migrations-Tests sagen, ob man den alten Code gefahrlos entfernen darf. Das ist eine andere Qualitaetsdimension.

10.3 Beispiel

JAVA
var decision = new RemovalGuard().canRemove(plan, gates, remainingFlagReferences);
assertFalse(decision.allowed(), "cleanup must be blocked while flag is referenced");

11. Finale Checkliste

11.1 Fachlich

Alle kritischen Business-Szenarien sind abgenommen, historische Sonderfaelle sind erklaert, Parity-Diffs sind entweder geloest oder bewusst akzeptiert.

11.2 Technisch

Build, Tests, Architekturregeln, Linkchecks, Runbooks, Monitoring und Deployment-Pipeline sind aktualisiert. Legacy-Abhaengigkeiten sind nicht nur ungenutzt, sondern entfernt.

11.3 Diagramm

Finale Checkliste

12. Produktionshinweise und Anti-Patterns

12.1 Anti-Pattern: permanente Feature Flags

Ein Migrationsflag ohne Ablaufdatum ist fast immer ein kuenftiger Produktionsfehler. Nach Monaten weiss niemand mehr, welche Kombinationen erlaubt sind. Deshalb braucht jeder Flag Owner, Ablaufdatum, Entfernen-Plan und Teststrategie.

12.2 Anti-Pattern: Tabellen sofort droppen

Direktes Droppen alter Tabellen im Cutover-Release spart scheinbar Arbeit, nimmt aber jede Chance auf Diagnose und Wiederherstellung. Besser ist ein kontrolliertes Retention- und Archivierungskonzept.

12.3 Anti-Pattern: Dokumentation vergessen

Wenn Code modernisiert wird, aber Runbooks, Dashboards und Support-Wissen alt bleiben, entsteht ein hybrides System im Kopf der Organisation. Deep Refactoring endet erst, wenn auch Betriebswissen modernisiert ist.

Code-Lab

Das Lab liegt unter code/legacy-strangler-cleanup-Kapitel und kompiliert mit JDK 21 ohne externe Dependencies. Verwendete Patterns sind im Code kommentiert und in docs/design-patterns.html dokumentiert.

Code-Labs zu diesem Kapitel

JVM & NebenläufigkeitSpeicher, GC, Profiling, Virtual Threads, Bulkheads und Performance.
15. JVM, Speicher, GC und Profiling

Kapitelkompass

Vom Symptom zur JVM-Hypothese

Heap, GC, CPU, Threads und Container müssen als korrelierter Snapshot gelesen werden; eine Einzelmetrik beweist keine Ursache.

SymptomAusgangspunktSnapshotEinordnenHypotheseAbsichernVergleichErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Heap und RSS trennen
  • GC-Signale lesen
  • CPU und Locks unterscheiden
  • Vorher/Nachher messen

Praxisfall

p95 steigt, Heap liegt bei 88 Prozent und die Allokationsrate erreicht 240 MiB/s.

Entscheidung

Erst Evidenz mit JFR, GC-Log, Thread Dump oder NMT sichern; danach genau eine Variable ändern.

!

Typisches Risiko

Mehr Heap oder ein anderer Collector kann Symptome verschieben und Container-Restarts verschärfen.

Schnelle Einordnung
SignalLesartNächster Schritt
Heap hoch, RSS passendJava-Heap oder RetentionJFR Allocation und Heap nach GC untersuchen
Heap moderat, RSS hochNative Memory oder Thread-StacksNMT, Direct Buffers und Threadzahl prüfen
CPU hoch, wenig BlockingHot Path oder GC-ArbeitExecution Profile und GC-Zeit korrelieren

JVM, Speicher, GC, Classloading und Profiling

1. Orientierung: JVM Deep Dive statt JVM-Mythen

In Enterprise-Projekten wird die JVM oft erst beachtet, wenn ein Incident läuft: CPU 100 %, Latenz steigt, Pods werden wegen OOMKilled beendet oder ein Batch wird jede Nacht langsamer. Ein Deep Dive beginnt nicht mit Tuning-Flags, sondern mit einem Mentalmodell: Java-Code wird zu Bytecode, die JVM lädt Klassen, führt Methoden aus, optimiert Hot Paths, verwaltet Speicher, plant Threads und räumt nicht mehr erreichbare Objekte auf.

Typischer Fehler: Man sucht sofort nach dem richtigen GC-Flag. Besser ist zuerst die Diagnose: Ist das Problem CPU-bound, Allocation-bound, IO-bound, lock-bound, classloading-bezogen oder ein falsches Container-Limit? Erst dann lohnt Tuning.

2. Java-Code, Bytecode, Interpreter und JIT

Der Compiler javac übersetzt Java-Quelltext in Bytecode. Dieser Bytecode ist nicht Maschinencode, sondern eine plattformunabhängige Zwischenform. Die JVM interpretiert zunächst, erkennt häufig ausgeführte Hot Paths und kann diese durch den JIT-Compiler optimieren.

Schlechte Schlussfolgerung: “Java ist langsam, weil interpretiert.” In Wirklichkeit ist moderne JVM-Ausführung eine Kombination aus Interpretation, Profiling und Just-in-Time-Kompilierung.

JAVA
// Ein kleiner Hot Path: viele Aufrufe erlauben der JVM Optimierungen.
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
    sum += priceInCent(i);
}

Produktionshinweis: Microbenchmarks ohne Warmup sind meistens falsch. Ein einmaliger Methodenaufruf misst oft Classloading, JIT-Warmup, Allocation und Betriebssystemeffekte mit.

3. Heap, Stack und Metaspace

Der Heap enthält Objekte und Arrays. Der Stack enthält pro Thread Frames und lokale Referenzen. Der Metaspace enthält Klassenmetadaten. Diese Bereiche haben unterschiedliche Fehlerbilder.

Wenn der Heap voll wirkt, ist die erste Frage nicht “welcher GC?”, sondern: Welche Objekte bleiben nach einer Full-GC-ähnlichen Bereinigung erreichbar? Wenn der Stack betroffen ist, sucht man eher nach Rekursion oder zu vielen verschachtelten Aufrufen. Wenn Metaspace wächst, denkt man an Classloader, dynamische Proxies, Plugins, Deployments oder Leaks in Container-Umgebungen.

4. Objektlebensdauer und Allocation Pressure

Viele kurzlebige Objekte sind für die JVM normal. Problematisch wird es, wenn pro Request unnötig große Objektgraphen entstehen oder wenn Objekte länger leben als gedacht.

Schlechte Lösung:

JAVA
// jedes Mapping erzeugt unnötig große Zwischenstrukturen
List<OrderView> views = orders.stream()
.map(order -> new OrderView(order, loadAllCustomerData(order.customerId())))
.toList();

Besser ist eine bewusste Sicht: benötigte Felder laden, Batch-Zugriff nutzen, Objektgraph klein halten. Deep-Dive-Frage: Welche Objekte entstehen pro Request, wie groß sind sie, wie lange bleiben sie erreichbar, und wo werden sie referenziert?

5. Garbage Collection: was GC kann und was nicht

Garbage Collection sammelt nicht mehr erreichbare Objekte. Sie repariert keine schlechten Referenzpfade. Ein Memory Leak in Java bedeutet oft: Ein Objekt ist fachlich tot, aber technisch noch erreichbar.

Wichtige Beobachtung: Wenn die Heap-Baseline nach GC langsam steigt, ist das verdächtig. Wenn der Heap zwischen GCs stark schwankt und danach wieder fällt, ist das oft normale Allocation Pressure. Deshalb ist eine einzelne Heap-Kurve ohne GC-Ereignisse schwer interpretierbar.

6. Memory Leak: Referenzpfade verstehen

Ein Leak entsteht häufig durch statische Collections, Caches ohne Begrenzung, ThreadLocals, Listener, Scheduler, Classloader oder falsch gehaltene Session-Daten.

JAVA
// Pattern: Anti-Pattern - unbounded static registry.
private static final List<byte[]> retained = new ArrayList<>();

Besser: klare Owner, begrenzte Caches, Lifecycle-Methoden, Weak References nur dort, wo sie fachlich passen, und Messung über Heap Dumps oder JFR.

7. Caches: hilfreich, gefährlich, erklärungspflichtig

Ein Cache ist ein bewusstes Produktivitäts- und Performance-Werkzeug. Er ist aber auch ein Speichervertrag. Jeder Cache braucht Antwort auf diese Fragen: Maximalgröße? TTL? Invalidation? Metriken? Warmup? Was passiert bei falschen Daten?

JAVA
// Pattern: Bounded Cache - begrenzt retained objects.
new RetainedCache<String, byte[]>(100);

Ein unbounded Cache ist im Enterprise-Kontext selten akzeptabel. Er kann Tests bestehen und trotzdem nach Wochen Produktion ausfallen.

8. Classloading und Metaspace

Classloading ist im Alltag unsichtbar, bis es schiefgeht. In Servern, Plugin-Systemen, Application Servern und Testumgebungen können Classloader über Deployments hinweg Klassen und statische Referenzen halten.

Typischer Fehler: Ein statischer Singleton hält eine Klasse aus einem alten Deployment. Dadurch bleibt der Classloader erreichbar, und damit bleiben Klassenmetadaten und Objekte erhalten. Das ist besonders in Legacy-Enterprise-Umgebungen relevant.

9. Threading, Stack und Blocking

Jeder Plattform-Thread bringt eigenen Stack und Scheduling-Kosten mit. Viele blockierende Threads können Speicher und Scheduler belasten. Virtual Threads verändern die Kostenstruktur, lösen aber nicht automatisch Datenbank-Connection-Pool-Grenzen, Locking-Probleme oder externe Rate Limits.

Deep-Dive-Frage: Warten Threads auf IO, auf Locks, auf CPU oder auf Ressourcen wie DB-Verbindungen? Ohne diese Unterscheidung ist jedes Tuning geraten.

10. Profiling ohne Selbstbetrug

Profiling ist ein Kreislauf: Symptom beobachten, messen, Hypothese formulieren, eine Änderung machen, erneut messen.

Schlechter Prozess: “Wir erhöhen Heap und hoffen.” Besser: Heap nach GC prüfen, Allocation Rate messen, Hot Methods identifizieren, Thread States betrachten und nur eine Änderung auf einmal testen.

11. JMX, MXBeans und einfache Runtime-Messung

Schon mit Standard-JDK-Mitteln kann man viel sehen: MemoryMXBean, GarbageCollectorMXBean, ClassLoadingMXBean und ThreadMXBean. Diese APIs ersetzen kein professionelles Profiling, helfen aber beim Aufbau eines mentalen Modells.

JAVA
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
long used = memory.getHeapMemoryUsage().getUsed();

Im Code-Lab wird genau das genutzt: ohne externe Library, damit die Konzepte sichtbar bleiben.

12. GC Logs und Ereignisse lesen

GC Logs sind keine Magie. Man betrachtet Zeitpunkte, Pause, vor/nach Speicher, Ursache und Häufigkeit. Entscheidend ist nicht nur eine lange Pause, sondern der Zusammenhang mit Last, Allocation Rate, Old-Generation-Wachstum und Container-Limits.

Naive Interpretation: “GC läuft oft, also ist GC schlecht.” Besser: Warum läuft er oft? Sind viele kurzlebige Objekte normal? Bleibt nach GC zu viel erhalten? Ist Heap zu klein oder gibt es Retention?

13. Container, Kubernetes und JVM-Limits

In Containern zählt nicht nur -Xmx, sondern das Zusammenspiel aus Container Memory Limit, Heap, Metaspace, Code Cache, Thread Stacks, Direct Buffers und nativen Strukturen. Ein Pod kann OOMKilled werden, obwohl der Java-Heap nicht am Maximum ist.

Produktionshinweis: Heap sollte nicht blind auf das Container-Limit gesetzt werden. Die JVM braucht außerhalb des Heaps ebenfalls Speicher.

14. Typische Produktionsvorfälle

Beispiele: Ein Report lädt 500.000 Entities vollständig in den Heap. Ein Cache wächst ohne Begrenzung. Ein ThreadLocal hält Request-Daten. Ein Scheduler erzeugt parallele Jobs. Ein Classloader-Leak tritt nach Redeploy auf. Eine neue JSON-Library allokiert massiv Zwischenobjekte.

Die gemeinsame Frage lautet: Welche Ressource wird knapp, warum wird sie knapp, und welche fachliche Änderung hat das ausgelöst?

15. Code-Walkthrough: das Kapitel-Lab

Das Code-Lab zeigt absichtlich keine Framework-Magie. Es nutzt JDK-21-Mittel: MXBeans, bounded Cache, Leak-Simulation, WeakReference, GC-Snapshot, einfachen Timer, Thread-State-Sampler und GC-Log-Parser.

Wichtig: Die Beispiele sind klein genug zum Kompilieren, aber fachlich an Produktionsproblemen orientiert. Sie zeigen nicht “so tunen wir GC”, sondern “so denken wir über Speicher, Referenzen und Messung”.

16. Tests und Messgrenzen

Performance-Tests sind empfindlich. Die Tests im Lab prüfen deshalb keine exakten Zeiten, sondern Eigenschaften: begrenzter Cache entfernt ältere Einträge, Registry kann geleert werden, Parser erkennt GC-Zeilen, Timer zeichnet Samples auf, Classloading-Probe liefert sinnvolle Daten.

Exakte Latenztests gehören in kontrollierte Benchmarks. Für Architektur-Lernzwecke sind Eigenschaftstests robuster.

17. Checkliste für JVM-Incidents

  1. Was ist das Symptom: CPU, Speicher, Latenz, OOMKill, Thread-Stau? 2. Gibt es eine Änderung: Release, Last, Datenvolumen, Konfiguration? 3. Was sagen Heap nach GC, Thread States, GC-Pausen, Container-Metriken? 4. Welche Hypothese testen wir? 5. Welche Änderung machen wir einzeln? 6. Wie messen wir Erfolg?

Diese Checkliste verhindert Aktionismus.

18. Lernaufgaben

  1. Erweitere den Cache um TTL. 2. Ergänze Metriken für Hit/Miss/eviction. 3. Simuliere einen ThreadLocal-Leak und entferne ihn. 4. Baue einen Parser für einfache GC-Pausenstatistik. 5. Vergleiche große Objektgraphen mit kleinen DTO-Projektionen. 6. Schreibe einen Incident-Bericht für steigende Heap-Baseline.

Code-Labs zu diesem Kapitel

16. Virtual Threads, Bulkheads und Performance

Kapitelkompass

Leichte Threads ersetzen keine Kapazität

Virtual Threads vereinfachen blockierenden Code, aber Datenbank, Socket, CPU und Fremdsystem bleiben begrenzte Ressourcen.

TaskAusgangspunktVirtual ThreadEinordnenBulkheadAbsichernDownstreamErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • I/O und CPU trennen
  • Pinning erkennen
  • Bulkheads dimensionieren
  • Limits messen

Praxisfall

10.000 Tasks starten, aber der Connection Pool besitzt nur 40 Verbindungen.

Entscheidung

Virtual Threads für wartelastige Arbeit nutzen und jeden knappen Downstream separat begrenzen.

!

Typisches Risiko

Unbegrenzte Parallelität erzeugt Warteschlangen, Timeouts und Speicherlast statt Durchsatz.

Schnelle Einordnung
SignalLesartNächster Schritt
Viele wartende TasksI/O-boundVirtual Threads plus Downstream-Bulkhead
CPU dauerhaft gesättigtCPU-boundParallelität am Core-Budget begrenzen
Pinned Threadssynchronized oder native BlockadeJFR-Pinning analysieren und kritischen Abschnitt verkürzen

Thema: Virtual Threads, Bulkheads, Connection-Pool-Grenzen, CPU vs I/O, Rate Limits und Produktions-Performance.

01. Orientierung: Warum Virtual Threads kein Performance-Zauberstab sind

Virtual Threads in Java 21 verändern, wie man blockierenden Enterprise-Code modelliert. Sie machen blockierendes I/O nicht kostenlos, aber sie entfernen einen großen Teil der alten Threadpool-Kosten. Der wichtigste Deep-Dive-Punkt lautet: Virtual Threads vergrößern keine Datenbank-Connection-Pools, keine HTTP-Rate-Limits und keine CPU-Kerne.

Ein typischer Fehler in Enterprise-Projekten ist die Gleichung: mehr Threads = mehr Durchsatz. Diese Gleichung stimmt fast nie. Mehr Threads helfen nur, wenn die Arbeit überwiegend wartet und die wartende Ressource nicht selbst knapp ist. Ein Order-System kann 1000 parallele Virtual Threads erzeugen, aber wenn nur 30 Datenbankverbindungen vorhanden sind, entsteht der Engpass an anderer Stelle.

Schlechte Entscheidung: jeden eingehenden Request beliebig viele Downstream-Calls starten lassen. Bessere Entscheidung: fachliche Nebenläufigkeit zulassen, aber knappe Ressourcen explizit schützen: Bulkheads, Timeouts, Rate Limits, Queue-Grenzen und messbare SLOs.

Merksatz: Virtual Threads sind ein Modellierungsgewinn für I/O-bound Workloads. Für CPU-bound Workloads braucht man weiterhin begrenzte Parallelität, Profiling und effiziente Algorithmen.

02. Threading-Grundlagen: Platform Thread, Virtual Thread, Carrier Thread

Ein Platform Thread ist ein klassischer JVM-Thread, der typischerweise auf einem Betriebssystem-Thread basiert. Er ist relativ teuer: Stack, Scheduling und Kontextwechsel sind nicht gratis. Ein Virtual Thread ist dagegen eine leichte Ausführungseinheit der JVM. Er kann blockieren, ohne dauerhaft einen Platform Thread zu blockieren. Die JVM parkt den Virtual Thread und verwendet Carrier Threads für tatsächlich laufende Arbeit.

Trotzdem bleiben einige Regeln unverändert. Synchronisierte Blöcke, native Calls, CPU-Schleifen und knappe externe Ressourcen können weiterhin begrenzen. Der Code sieht oft einfacher aus, aber die Architektur muss sauberer werden, nicht beliebiger.

Schlechtes Mentalmodell:

TEXT
Virtual Threads = ich kann alles ohne Grenze parallelisieren

Besseres Mentalmodell:

TEXT
Virtual Threads = ich kann fachliche I/O-Arbeit einfach ausdrücken, muss aber knappe Ressourcen explizit begrenzen

Praktische Entscheidung: Für REST-Calls, Dateizugriff, HTTP-Clients und DB-Zugriffe kann Virtual Thread Code lesbarer machen. Für Hashing, Kompression, große JSON-Transformationen oder Report-Berechnung muss CPU-Parallelität limitiert bleiben.

03. I/O-bound vs CPU-bound: Der wichtigste Performance-Schnitt

I/O-bound bedeutet: Die Anwendung wartet viel auf Netzwerk, Datenbank, Dateisystem oder externe APIs. CPU-bound bedeutet: Die Anwendung rechnet tatsächlich. Virtual Threads helfen besonders bei I/O-bound Workloads, weil man viele wartende Operationen günstiger modellieren kann.

Ein Order-Enrichment-Use-Case ist oft I/O-bound: Order laden, Payment-Status lesen, Inventory lesen, Customer-Risk lesen. Ein PDF-Report mit Millionen Zeilen ist eher CPU- und Memory-bound. Dort helfen mehr Virtual Threads nicht, sondern können sogar Scheduling-Druck erzeugen.

Checkliste: - Wartet der Code auf externe Systeme? Dann Virtual Threads prüfen. - Rechnet der Code intensiv? Dann CPU-Pool begrenzen. - Ist der Engpass eine Datenbankverbindung? Dann Pool, Query und Index prüfen. - Ist der Engpass ein externer Dienst? Dann Rate Limit und Bulkhead setzen. - Ist der Engpass Speicher? Dann Allocation, Caches und GC prüfen.

04. Schlechte Lösung: Alles parallel ohne Grenze starten

Die naive Lösung sieht attraktiv aus: jede Teilinformation wird parallel geladen. In kleinen Tests ist sie schnell. In Produktion kann sie Downstreams überfahren.

JAVA
// Schlechte Idee: Keine Grenze für Downstream-Calls.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<OrderDto> order = executor.submit(() -> orderClient.load(orderId));
    Future<PaymentDto> payment = executor.submit(() -> paymentClient.load(orderId));
    Future<InventoryDto> inventory = executor.submit(() -> inventoryClient.load(orderId));
    return OrderView.of(order.get(), payment.get(), inventory.get());
}

Warum das gefährlich ist: - get() ohne Timeout kann Request-Threads lange halten. - Der Payment-Service kann überlastet werden. - Ein DB-Pool wird nicht größer, nur weil Threads leichter sind. - Fehlerpfade sind unklar: Teilantwort, Abbruch oder Retry? - Es gibt keine Metrik, welche Ressource gerade blockiert.

Eine bessere Lösung definiert pro externer Ressource Grenzen und Antwortverhalten.

05. Bessere Lösung: Bulkhead + Timeout + Teilantwort

Ein Bulkhead schützt eine Ressource vor Überlast. Ein Timeout verhindert unendliches Warten. Eine fachliche Teilantwort verhindert, dass ein optionaler Downstream das ganze System blockiert.

Das wichtige Designprinzip: Die Grenze gehört nicht irgendwo in einen technischen Helper, sondern wird pro fachlichem Port begründet. Payment kann kritischer sein als Recommendations. Inventory kann für Checkout hart erforderlich sein, aber für eine Historienansicht optional.

JAVA
// Pattern: Bulkhead - begrenzt parallele Aufrufe eines knappen Downstreams.
public final class Bulkhead {
    private final Semaphore permits;
    public Bulkhead(int maxConcurrentCalls) {
        this.permits = new Semaphore(maxConcurrentCalls);
    }
public <T> T call(Supplier<T> work) {
    if (!permits.tryAcquire()) {
        throw new BulkheadRejectedException("downstream is saturated");
    }
try {
    return work.get();
}
finally {
    permits.release();
}
}
}

Produktionsregel: Bulkhead-Ablehnungen sind nicht automatisch Fehler. Sie sind ein bewusstes Schutzsignal und müssen als Metrik sichtbar sein.

06. Connection-Pool-Grenzen: Warum 1000 Virtual Threads bei 30 Connections warten

Datenbanken sind häufig der eigentliche Engpass. Ein JDBC-Pool mit 30 Connections kann nicht 1000 echte SQL-Operationen gleichzeitig ausführen. Virtual Threads machen das Warten günstiger, aber sie lösen keine DB-Kapazität.

Ein gefährliches Muster ist: große Parallelität im Service, kleine Datenbank, keine Timeouts. Das erzeugt Warteschlangen im Connection Pool, lange Request-Latenzen und am Ende Timeouts auf höherer Ebene.

Gute Architektur trennt drei Grenzen: - Request-Concurrency: Wie viele Requests nimmt der Service an? - Downstream-Concurrency: Wie viele parallele DB/HTTP-Aufrufe erlauben wir? - Queue-Time: Wie lange darf Arbeit auf eine knappe Ressource warten?

JAVA
// Pattern: Resource Pool Simulation - zeigt, dass die Ressource knapper ist als der Thread.
public final class SimulatedConnectionPool {
    private final Semaphore connections;
    public SimulatedConnectionPool(int size) {
        this.connections = new Semaphore(size);
    }
public <T> T withConnection(Supplier<T> query) {
    if (!connections.tryAcquire()) {
        throw new PoolExhaustedException("no database connection available");
    }
try {
    return query.get();
}
finally {
    connections.release();
}
}
}

Deep-Dive-Falle: Wenn man den DB-Pool einfach vergrößert, kann man die Datenbank selbst überlasten. Die richtige Frage ist nicht nur „wie viele Connections“, sondern „wie viele parallele Queries verträgt die DB bei akzeptabler Latenz?“

07. Timeouts: Technische Grenze mit fachlicher Bedeutung

Ein Timeout ist nicht nur ein technischer Wert. Er sagt aus, wie lange ein Benutzer, Prozess oder Downstream warten darf, bevor eine andere fachliche Entscheidung getroffen wird.

Typische Abstufung: - Checkout-Payment: eher hart, aber mit klarer Fehlermeldung. - Order-Historie mit Recommendation: Recommendation darf fehlen. - Reporting-Refresh: kann asynchron nachholen. - Audit-Schreiben: darf nicht stillschweigend verloren gehen.

Timeouts müssen abgestimmt sein. Wenn der Client nach 1 Sekunde abbricht, der Server aber 10 Sekunden weiterarbeitet, entstehen Geisterlast und unnötige Downstream-Calls.

Produktionshinweise: - Timeouts pro Downstream dokumentieren. - Timeout-Gründe in Metrics trennen. - Cancellation respektieren. - Keine unbounded Retries nach Timeout. - Teilantworten fachlich begründen.

08. Rate Limiting und Backpressure: Last kontrolliert abweisen

Wenn ein System überlastet ist, muss es nicht jede Arbeit annehmen. Backpressure bedeutet: Das System signalisiert, dass es gerade keine weitere Arbeit aufnehmen kann. Das kann durch HTTP 429, Queue-Limits, Bulkhead-Rejections oder interne Rate Limits passieren.

Schlechte Systeme stauen unbegrenzt. Gute Systeme lehnen früh, messbar und kontrolliert ab. Frühes Ablehnen schützt Benutzer oft besser als minutenlanges Warten.

JAVA
// Pattern: Rate Limiter - begrenzt Aufrufe pro Zeitfenster.
public final class WindowRateLimiter {
    private final int maxPerWindow;
    private final long windowMillis;
    private long windowStart = System.currentTimeMillis();
    private int used;
    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        if (now - windowStart >= windowMillis) {
            windowStart = now;
            used = 0;
        }
    if (used >= maxPerWindow) return false;
    used++;
    return true;
}
}

In Enterprise-Systemen sollte ein Rate Limit nie nur zufällig im Code stehen. Es braucht einen Grund: Lizenzlimit, Downstream-SLO, Datenbankkapazität, Schutz vor Retry-Stürmen oder faire Tenant-Nutzung.

09. Structured Concurrency als Denkmodell

Structured Concurrency bedeutet: Parallele Teilaufgaben gehören zu einem gemeinsamen Scope. Wenn der Scope endet, sind auch die Teilaufgaben beendet oder abgebrochen. Dadurch wird Nebenläufigkeit kontrollierbarer.

Auch wenn man nicht jede Preview-API produktiv nutzt, ist das Denkmodell wertvoll: Teilaufgaben sollen eine gemeinsame Lebensdauer haben, Fehler sollen zentral entschieden werden und Timeouts sollen den ganzen Scope betreffen.

Beispielhafte Fachregel: - Order-Daten sind Pflicht. - Payment-Status ist Pflicht für Checkout. - Recommendation ist optional. - Inventory ist für Checkout Pflicht, für Historie optional.

Deep-Dive-Punkt: Nicht jede parallele Aufgabe ist gleich wichtig. Die fachliche Kritikalität bestimmt, ob man abbricht, degradiert oder asynchron nachholt.

10. CPU-bound Workloads: Warum Virtual Threads hier nicht helfen

CPU-bound Workloads verbrauchen Rechenzeit. Beispiele: Kompression, Verschlüsselung, große JSON-Transformationen, Report-Berechnungen, Sortieren großer Datenmengen. Hier helfen Virtual Threads nicht, weil die CPU-Kerne der Engpass sind.

Schlechter Code startet pro Datensatz einen Virtual Thread und erzeugt Scheduling-Overhead. Besser ist eine begrenzte Parallelität passend zur CPU und zur Speicherlast.

JAVA
// Für CPU-bound Arbeit bewusst begrenzen.
int cpuParallelism = Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
ExecutorService cpuPool = Executors.newFixedThreadPool(cpuParallelism);

Produktionshinweis: CPU-bound Optimierung beginnt mit Profiling, nicht mit Thread-Anzahl. Erst messen, ob CPU, Allocation, Lock Contention oder I/O wartet.

11. Latenz, Durchsatz und Sättigung gemeinsam lesen

Performance-Diskussionen scheitern oft, weil nur eine Zahl betrachtet wird. Latenz sagt, wie lange ein einzelner Vorgang dauert. Durchsatz sagt, wie viele Vorgänge pro Zeit verarbeitet werden. Sättigung sagt, wie nah eine Ressource an ihrer Grenze ist.

Ein Service kann hohen Durchsatz haben und trotzdem schlechte P99-Latenz. Ein anderer Service kann niedrige CPU haben, aber trotzdem langsam sein, weil alle Threads auf DB-Connections warten.

Metriken für Runbooks: - P50/P95/P99-Latenz pro Endpoint - DB-Pool active/idle/waiting - Downstream-Timeouts - Bulkhead-Rejections - Queue-Länge - CPU-Auslastung - GC-Pausen - Allocation Rate - Error Budget Burn

12. Produktions-Performance: Incident-Checkliste

Bei einem Performance-Incident sollte man nicht planlos Parameter erhöhen. Eine saubere Diagnose läuft in Schritten.

Checkliste: 1. Welcher SLO ist verletzt: Latenz, Fehlerquote, Durchsatz? 2. Ist die Last höher oder die Kapazität niedriger? 3. Ist CPU hoch oder warten Threads? 4. Ist DB-Pool erschöpft? 5. Gibt es Downstream-Timeouts? 6. Gibt es Retry-Stürme? 7. Gibt es neue Deployments, Datenmengen oder Feature Flags? 8. Sind P99 und Durchschnitt auseinander gelaufen? 9. Ist das Problem tenant-spezifisch? 10. Welche Änderung kann sicher zurückgerollt werden?

Gute Teams dokumentieren nicht nur den Fix, sondern auch die Diagnosekette. Genau daraus entstehen bessere Bulkheads, Timeouts und Kapazitätsmodelle.

13. Code-Walkthrough: OrderViewService mit Grenzen

Das Code-Lab zeigt bewusst keinen Framework-Code. Es konzentriert sich auf die Architekturidee. OrderViewService startet fachlich parallele I/O-Aufgaben, aber jede knappe Ressource wird geschützt.

Wichtige Klassen: - OrderViewService koordiniert fachliche Teilaufrufe. - Bulkhead schützt externe Systeme. - SimulatedConnectionPool zeigt DB-Grenzen. - WindowRateLimiter begrenzt Downstream-Calls. - TimeoutPolicy kapselt Wartezeit. - MetricsRegistry sammelt technische Signale. - Kapitel prüft Szenarien.

Die Tests prüfen nicht nur „happy path“, sondern auch Ablehnung, Pool-Erschöpfung, Rate-Limit, Timeout-Entscheidung und CPU-vs-I/O-Klassifikation.

14. Anti-Patterns und bessere Entscheidungen

Anti-Patterns: - Virtual Threads ohne Downstream-Grenzen. - CPU-bound Arbeit massiv parallel starten. - DB-Pool als Performance-Regler missbrauchen. - Timeouts nur technisch setzen, nicht fachlich erklären. - Retries ohne Budget. - Bulkhead-Rejections als technische Bugs behandeln. - P99-Latenz ignorieren. - Keine Tenant-Grenzen.

Bessere Entscheidungen: - Ressourcen pro Port begrenzen. - Timeouts pro Use Case begründen. - Optionalität fachlich modellieren. - Last früh abweisen, wenn Schutz notwendig ist. - SLOs in Tests und Runbooks übersetzen. - Profiling vor Optimierung. - Virtual Threads für lesbaren I/O-Code nutzen, nicht als Ersatz für Architektur.

15. Lernaufgaben

Aufgaben: 1. Erhöhe im Code-Lab die parallelen Requests und beobachte Bulkhead-Rejections. 2. Reduziere den simulierten DB-Pool und erkläre die Wirkung. 3. Baue einen optionalen Recommendation-Port ein, der bei Timeout eine Teilantwort liefert. 4. Erweitere MetricsRegistry um P95-Messung. 5. Ergänze einen Test, der CPU-bound Arbeit begrenzt. 6. Beschreibe, welche Werte als SLO für Checkout sinnvoll sind. 7. Formuliere ein Runbook für „P99 steigt, CPU niedrig, DB-Pool waiting hoch“.

Code-Labs zu diesem Kapitel

FrameworksSpring Boot, Jakarta EE, Quarkus und Micronaut im direkten Vergleich.
17. Framework Deep Dive: Spring Boot, Jakarta EE, Quarkus und Micronaut

Kapitelkompass

Frameworks nach Betriebsmodell auswählen

Spring Boot, Jakarta EE, Quarkus und Micronaut unterscheiden sich nicht nur in APIs, sondern in Build, Startzeit, Ökosystem und Betrieb.

AnforderungAusgangspunktVergleichEinordnenPrototypAbsichernEntscheidungErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Entscheidungskriterien gewichten
  • Runtime-Modell verstehen
  • Native Trade-offs kennen
  • Migration realistisch planen

Praxisfall

Ein Team benötigt schnelle Skalierung, bekannte Libraries und langfristig verfügbare Betriebskenntnisse.

Entscheidung

Nach Team, Plattform, Libraries und SLOs entscheiden; Benchmarks allein reichen nicht.

!

Typisches Risiko

Ein Frameworkwechsel ohne fachlichen oder betrieblichen Nutzen erzeugt nur Migrationskosten.

Dieser Kapitel ist der Abschlussrun der geplanten 24 Deep-Dive-Vertiefungen. Er behandelt Frameworks nicht als Feature-Liste, sondern als Architekturentscheidung: Wie bleibt der fachliche Kern stabil, wenn HTTP, Dependency Injection, Transactions, Configuration, Observability, Packaging und Runtime je nach Framework anders aussehen?

01. Warum Framework-Deep-Dive mehr ist als Annotationen

In Enterprise-Projekten wird Framework-Wissen oft falsch verstanden. Viele Teams lernen zuerst @RestController, @Transactional, @Inject, @Path oder @Controller und glauben dann, die Architektur sei erledigt. Das ist gefaehrlich. Ein Framework erzeugt Objektgraphen, startet Runtime-Komponenten, bindet HTTP, verwaltet Konfiguration und integriert Infrastruktur. Es entscheidet aber nicht automatisch, wo deine fachlichen Grenzen liegen.

Ein guter Framework-Deep-Dive fragt deshalb nicht: “Welche Annotation brauche ich?” Sondern: “Welche Verantwortung liegt im Framework-Adapter, welche im Application Service, welche im Domain Model und welche im Infrastruktur-Port?” Diese Trennung bestimmt, ob ein Projekt nach drei Jahren noch wartbar ist.

Typische schlechte Loesung:

JAVA
@RestController
class OrderController {
    @PostMapping("/orders")
    public ResponseEntity<?> place(@RequestBody OrderRequest request) {
        // Validierung, Preisberechnung, Payment-Aufruf, Repository-Zugriff,
        // Eventversand und HTTP-Fehlerbehandlung in einer Klasse.
    }
}

Das Problem ist nicht die Annotation. Das Problem ist, dass HTTP, Fachlogik, Transaktion, externe Systeme und Fehlerabbildung zusammenkleben. In einem kleinen Demo-Projekt sieht das bequem aus. In einem Enterprise-Projekt fuehrt es zu schlechten Tests, schwerer Migration und unsauberen Fehlergrenzen.

Bessere Regel: Framework-Code ist Adapter-Code. Fachlogik lebt im Use Case und Domain Model.

02. Gemeinsame Fachfunktion fuer vier Frameworks

In diesem Kapitel verwenden Spring Boot, Jakarta EE, Quarkus und Micronaut denselben fachlichen Use Case: Bestellung anlegen. Alle Adapter rufen denselben PlaceOrderUseCase auf. Dadurch sieht man, was wirklich Framework-spezifisch ist und was fachlich stabil bleiben muss.

Der Request-Flow ist immer gleich:

  1. HTTP-Request trifft Framework-Adapter.
  2. Adapter validiert transportnahe Form und mappt DTOs.
  3. Adapter ruft frameworkfreien Use Case.
  4. Use Case orchestriert Domain, Ports und Transaktionsrahmen.
  5. Adapter mappt fachliches Result auf HTTP-Status und Problem-Body.

Wichtig: Der fachliche Kern weiss nicht, ob er von Spring, Jakarta, Quarkus, Micronaut, CLI, Batch oder Test aufgerufen wurde.

03. Dependency Injection und Lifecycle tief verstehen

Dependency Injection ist kein Ersatz fuer gutes Design. DI beantwortet: “Wie entstehen Objekte und wie werden sie verdrahtet?” Es beantwortet nicht: “Welche Module duerfen voneinander abhaengen?”, “Wo endet die Transaktion?” oder “Welche Fehler sind fachlich?”.

Bei Spring Boot passiert viel zur Laufzeit und durch Auto-Konfiguration. Jakarta EE arbeitet staerker ueber Standards und Container-Services. Quarkus verschiebt vieles in Build-Time-Augmentation. Micronaut setzt stark auf Compile-Time-Metadaten. Diese Unterschiede beeinflussen Startzeit, Diagnose, Reflection, Native-Image-Faehigkeit und Debugging.

Deep-Dive-Regel: Je mehr ein Framework automatisch macht, desto wichtiger werden Architekturtests, Startdiagnose, Konfigurationsreport und klare Modulgrenzen.

04. Spring Boot Deep Dive

Spring Boot ist in vielen Enterprise-Teams beliebt, weil es schnell produktiv macht. Auto-Konfiguration, Starter, Actuator, Testing-Support und ein grosses Oekosystem sind starke Vorteile. Die Kehrseite: Ein Projekt kann unbemerkt viele Abhaengigkeiten, versteckte Bean-Konflikte und unklare Auto-Konfigurationspfade bekommen.

Gute Spring-Boot-Architektur bedeutet nicht, ueberall Spring-Typen zu verwenden. Gute Architektur bedeutet: Spring erzeugt Adapter und Infrastruktur; der Kern bleibt sauber.

JAVA
package com.example.framework.deepdive.frameworks.spring;
import com.example.framework.deepdive.application.PlaceOrderUseCase;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.fakeannotations.RestController;
import java.util.*;
@RestController("orders")
// Pattern: Adapter - uebersetzt HTTP DTOs in den frameworkfreien Use Case.
public final class SpringBootOrderController {
    private final PlaceOrderUseCase useCase;
    public SpringBootOrderController(PlaceOrderUseCase useCase) {
        this.useCase = useCase;
    }
@com.example.framework.deepdive.fakeannotations.PostMapping("/orders")
public HttpResponse place(PlaceOrderHttpRequest request) {
    var command = new PlaceOrderCommand(request.requestId(), new CustomerId(request.customerId()), request.lines().stream().map(l -> new OrderLine(new Sku(l.sku()), l.quantity(), Money.eur(l.unitPrice()))).toList());
    return switch (useCase.place(command)) {
        case PlaceOrderResult.Accepted ok -> new HttpResponse(201, "accepted:" + ok.orderId().value() + ":" + ok.total());
        case PlaceOrderResult.Rejected bad -> new HttpResponse(statusFor(bad.error()), "problem:" + bad.error().code() + ":" + bad.error().message());
    };
}
private int statusFor(BusinessError error) {
    return switch (error) {
        case ValidationError e -> 400;
        case DuplicateRequest e -> 409;
        case PaymentDeclined e -> 422;
    };
}
}

Produktionshinweise:

  • Auto-Konfiguration immer sichtbar machen: welche Beans wurden erzeugt, welche nicht?
  • Starters nur bewusst aufnehmen, nicht “weil es geht”.
  • @Transactional nur an klaren Application-Service-Grenzen nutzen.
  • Domain-Klassen nicht mit Spring-Annotationen dekorieren.
  • Tests trennen: kleiner Use-Case-Test ohne Spring, Adapter-Test mit Web-Schicht, Integrationstest mit echter Infrastruktur.

05. Jakarta EE Deep Dive

Jakarta EE ist weniger “one jar owns everything” und staerker ein Standard-API- und Container-Modell. Das ist in Organisationen mit langen Lebenszyklen, Application-Server-Strategie und Standardisierungsdruck relevant. Der Vorteil ist Stabilitaet und API-Portabilitaet. Die Herausforderung ist, dass Runtime, Server-Konfiguration, Deployment und Versionen sehr bewusst zusammenpassen muessen.

Jakarta-Adapter sehen anders aus, aber sie duerfen dieselbe fachliche Regel nicht anders auslegen. Ein @Path-Resource ist genau wie ein Spring Controller: ein Adapter, kein Domain Service.

Typische Fehler:

  • Business-Logik in JAX-RS Resources.
  • Server-spezifische APIs tief im Fachcode.
  • Transaktionen an zu vielen kleinen Methoden statt an Use-Case-Grenzen.
  • fehlende Contract Tests beim Wechsel von Server oder Runtime.

06. Quarkus Deep Dive

Quarkus ist fuer moderne Container- und Cloud-Runtimes interessant, weil viele Entscheidungen in die Build-Zeit verlagert werden. Das kann Startzeit und Speicherprofil verbessern, fuehrt aber auch dazu, dass Extensions, Build-Konfiguration und Native-Image-Eignung wichtiger werden.

Ein Quarkus-Projekt braucht deshalb besonders klare Grenzen zwischen Build-Time-Metadaten, Runtime-Konfiguration und Fachmodell. Wer die Grenzen nicht zieht, bekommt zwar eine schnelle Runtime, aber weiterhin schlechte Architektur.

Deep-Dive-Fragen fuer Quarkus:

  • Welche Extensions sind wirklich noetig?
  • Welche Klassen werden durch Build-Time-Scanning erfasst?
  • Welche Reflection-Anforderungen entstehen?
  • Sind Tests fuer JVM- und native-nahe Laufzeit sinnvoll getrennt?
  • Bleibt die Fachlogik ohne Quarkus startbar?

07. Micronaut Deep Dive

Micronaut reduziert viele Runtime-Reflection-Mechanismen durch Compile-Time-Verarbeitung. Das ist fuer kleine Services, schnelle Starts und klare DI-Fehler attraktiv. Gleichzeitig muss das Team verstehen, welche Annotation Processing Schritte stattfinden und wie Konfiguration, Clients, Validation und Security in diesem Modell funktionieren.

Micronaut eignet sich gut, wenn ein Team explizite Compile-Time-Metadaten, geringe Laufzeitmagie und leichte Services moechte. Es ist aber kein Selbstlaeufer: Auch hier bleiben Modulgrenzen, Teststrategie und saubere Ports wichtiger als Framework-Features.

08. Transaktionen und Frameworks

Frameworks machen Transaktionen bequem, aber genau darin liegt die Gefahr. Eine Annotation sieht klein aus, kann aber grosse Laufzeitfolgen haben: Connection wird gebunden, Persistence Context entsteht, Locks koennen gehalten werden, externe Calls werden blockierend, Rollback-Regeln greifen oder greifen nicht.

Schlechte Regel: “Einfach @Transactional ueberall drauf.”
Bessere Regel: “Jeder Use Case hat eine bewusst dokumentierte Transaktionsgrenze.”

Im Code-Lab ist TransactionTemplate absichtlich ein Port. In echten Frameworks wuerde dieser Port durch Spring TransactionTemplate, Jakarta Transaktionen oder Quarkus/Micronaut Integration umgesetzt. Der Use Case kennt diese Details nicht.

JAVA
package com.example.framework.deepdive.application;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.ports.*;
import java.time.Clock;
import java.util.Objects;
// Pattern: Application Service - orchestriert Ports, transaktionale Grenze und Aggregate.
public final class PlaceOrderUseCase {
    private final OrderRepository orders;
    private final PaymentPort payments;
    private final OutboxPort outbox;
    private final TransactionTemplate tx;
    private final OrderNumberGenerator numbers;
    private final Clock clock;
    public PlaceOrderUseCase(OrderRepository orders, PaymentPort payments, OutboxPort outbox, TransactionTemplate tx, OrderNumberGenerator numbers, Clock clock) {
        this.orders = Objects.requireNonNull(orders);
        this.payments = Objects.requireNonNull(payments);
        this.outbox = Objects.requireNonNull(outbox);
        this.tx = Objects.requireNonNull(tx);
        this.numbers = Objects.requireNonNull(numbers);
        this.clock = Objects.requireNonNull(clock);
    }
public PlaceOrderResult place(PlaceOrderCommand command) {
    return tx.inTransaction(() -> {
        if (orders.findByRequestId(command.requestId()).isPresent()) return new PlaceOrderResult.Rejected(new DuplicateRequest("request already processed"));
        if (command.lines().isEmpty()) return new PlaceOrderResult.Rejected(new ValidationError("order must contain at least one line"));
        var order = Order.draft(numbers.next(), command.customerId(), command.lines());
        var payment = payments.authorize(command.customerId(), order.total());
        if (payment instanceof PaymentDecision.Declined declined) {
            order.reject(declined.reason(), clock); orders.save(command.requestId(), order); outbox.appendAll(order.pullEvents()); return new PlaceOrderResult.Rejected(new PaymentDeclined(declined.reason()));
        }
    order.accept(clock); orders.save(command.requestId(), order); outbox.appendAll(order.pullEvents()); return new PlaceOrderResult.Accepted(order.id(), order.total());
}
);
}
}

09. Runtime-Trade-offs und Entscheidungsmatrix

Framework-Auswahl ist keine reine Geschmacksfrage. Sie ist eine betriebliche Entscheidung. Man muss Team-Know-how, Oekosystem, Startup-Zeit, Memory Footprint, Standards, Cloud-Fit, Debugging, Monitoring, Testbarkeit, Migrationskosten und Lizenz-/Supportstrategie betrachten.

Entscheidungsmatrix:

Kriterium Spring Boot Jakarta EE Quarkus Micronaut
Produktivitaet sehr hoch mittel bis hoch hoch hoch
Standardisierung mittel sehr hoch mittel mittel
Container-Fokus hoch runtime-abhaengig sehr hoch hoch
Startzeit-Fokus mittel runtime-abhaengig hoch hoch
Oekosystem sehr gross server-/vendor-abhaengig wachsend fokussiert
Lernrisiko Auto-Magie Servermodell Build-Time-Modell Compile-Time-Modell
Beste Rolle universelle Enterprise-Services Standard-/Serverlandschaften cloud-native Services leichte, schnelle Services

10. Testing-Strategie fuer Framework-Projekte

Framework-Tests sind oft langsam, wenn man alles mit voller Runtime startet. Das ist nicht noetig. Ein gutes Testmodell trennt:

  1. Domain Tests ohne Framework.
  2. Application-Service-Tests mit Fakes.
  3. Adapter-Tests fuer HTTP/DTO/Statuscodes.
  4. Contract Tests fuer Ports.
  5. wenige, aber echte Integrationstests.
  6. Smoke Tests fuer Packaging und Runtime-Start.

Im Code-Lab wird derselbe Use Case ueber vier Adapter getestet. Das beweist: Frameworks duerfen unterschiedlich aussehen, aber fachliche Ergebnisse muessen gleich bleiben.

JAVA
package com.example.framework.deepdive;
import com.example.framework.deepdive.adapters.*;
import com.example.framework.deepdive.application.*;
import com.example.framework.deepdive.domain.*;
import com.example.framework.deepdive.frameworks.spring.*;
import com.example.framework.deepdive.frameworks.jakarta.*;
import com.example.framework.deepdive.frameworks.quarkus.*;
import com.example.framework.deepdive.frameworks.micronaut.*;
import java.time.Clock;
import java.util.List;
public final class FrameworkDeepDiveTestRunner {
    public static void main(String[] args) {
        sameUseCaseContractForEveryFramework();
        duplicateRequestIsConflict();
        declinedPaymentIsBusinessProblem();
        System.out.println("Kapitel");
    }
static PlaceOrderUseCase newUseCase(ApprovingPaymentAdapter payment, InMemoryOrderRepository orders, InMemoryOutbox outbox) {
    return new PlaceOrderUseCase(orders, payment, outbox, new SimpleTransactionTemplate(), new OrderNumberGenerator(), Clock.systemUTC());
}
static void sameUseCaseContractForEveryFramework() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = newUseCase(new ApprovingPaymentAdapter(), orders, outbox);
    var spring = new SpringBootOrderController(uc).place(new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-S", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00"))));
    var jakarta = new JakartaOrderResource(uc).place(new com.example.framework.deepdive.frameworks.jakarta.PlaceOrderHttpRequest("REQ-J", "C-1", List.of(new com.example.framework.deepdive.frameworks.jakarta.OrderLineDto("SKU", 1, "10.00"))));
    var quarkus = new QuarkusOrderResource(uc).place(new com.example.framework.deepdive.frameworks.quarkus.PlaceOrderHttpRequest("REQ-Q", "C-1", List.of(new com.example.framework.deepdive.frameworks.quarkus.OrderLineDto("SKU", 1, "10.00"))));
    var micronaut = new MicronautOrderController(uc).place(new com.example.framework.deepdive.frameworks.micronaut.PlaceOrderHttpRequest("REQ-M", "C-1", List.of(new com.example.framework.deepdive.frameworks.micronaut.OrderLineDto("SKU", 1, "10.00"))));
    assertEquals(201, spring.status());
    assertEquals(201, jakarta.status());
    assertEquals(201, quarkus.status());
    assertEquals(201, micronaut.status());
    assertEquals(4, orders.size());
    assertEquals(4, outbox.pendingCount());
}
static void duplicateRequestIsConflict() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = newUseCase(new ApprovingPaymentAdapter(), orders, outbox);
    var c = new SpringBootOrderController(uc);
    var req = new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-DUP", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00")));
    assertEquals(201, c.place(req).status());
    assertEquals(409, c.place(req).status());
}
static void declinedPaymentIsBusinessProblem() {
    var orders = new InMemoryOrderRepository();
    var outbox = new InMemoryOutbox();
    var uc = new PlaceOrderUseCase(orders, new DecliningPaymentAdapter(), outbox, new SimpleTransactionTemplate(), new OrderNumberGenerator(), Clock.systemUTC());
    var c = new SpringBootOrderController(uc);
    var r = c.place(new com.example.framework.deepdive.frameworks.spring.PlaceOrderHttpRequest("REQ-DEC", "C-1", List.of(new com.example.framework.deepdive.frameworks.spring.OrderLineDto("SKU", 1, "10.00"))));
    assertEquals(422, r.status());
    assertEquals(1, orders.size());
    assertEquals(1, outbox.pendingCount());
}
static void assertEquals(Object e, Object a) {
    if (!e.equals(a)) throw new AssertionError("expected " + e + " but got " + a);
}
}

11. Framework-Anti-Patterns

Die haeufigsten Anti-Patterns in Enterprise-Java-Projekten:

  • Domain Model mit Framework-Annotationen ueberladen.
  • Controller ruft direkt Repository.
  • @Transactional an jedem Service, ohne Use-Case-Grenze.
  • DTOs werden zu Entities und Entities werden zu API-Vertraegen.
  • Configuration Properties werden ueberall verteilt.
  • Security-Regeln stehen im Controller, aber nicht in fachlicher Policy.
  • Integrationstests ersetzen Unit Tests.
  • Auto-Konfiguration wird nicht verstanden.
  • Framework-Migration wird mit Fachrefactoring vermischt.

Gegenmittel:

  • Modulgrenzen dokumentieren.
  • Architecture Tests einfuehren.
  • Ports fuer Infrastruktur.
  • Adapter fuer Frameworks.
  • Contract Tests fuer Use Cases.
  • Migrationsschritte klein halten.

12. Framework-Wechsel realistisch planen

Ein Framework-Wechsel ist selten ein reines Code-Such-und-Ersetz-Projekt. Die eigentliche Frage lautet: Wie viel Fachlogik ist mit dem Framework verwoben? Wenn Business-Regeln in Controllers, Repositories und Annotationen versteckt sind, wird ein Wechsel teuer. Wenn der fachliche Kern sauber ist, ist der Wechsel vor allem Adapter- und Infrastrukturarbeit.

Migrationsreihenfolge:

  1. Use Cases aus Controllern extrahieren.
  2. Ports fuer Infrastruktur einfuehren.
  3. DTO-Mapping isolieren.
  4. Transaktionsgrenzen dokumentieren.
  5. Contract Tests schreiben.
  6. neuen Framework-Adapter parallel aufbauen.
  7. Traffic schrittweise umschalten.
  8. alte Adapter entfernen.

13. Code-Lab Walkthrough

Das Lab ist bewusst JDK-only, damit es ohne externe Framework-Downloads kompiliert. Es simuliert die Framework-Adapter durch kleine Annotationen und Controller-Klassen. Der Lernwert liegt nicht darin, echte Server zu starten, sondern die Architekturgrenze zu beweisen.

Wichtige Dateien:

Datei Zweck
PlaceOrderUseCase.java frameworkfreie Use-Case-Orchestrierung
Order.java Aggregate Root mit Domain Events
PaymentPort.java Port fuer externes Payment
OutboxPort.java Port fuer transaktionale Event-Vorbereitung
SpringBootOrderController.java Spring-artiger Adapter
JakartaOrderResource.java Jakarta-artiger Adapter
QuarkusOrderResource.java Quarkus-artiger Adapter
MicronautOrderController.java Micronaut-artiger Adapter
FrameworkDeepDiveTestRunner.java Contract Tests ueber alle Adapter

14. Produktionscheckliste

Vor produktiver Framework-Entscheidung klaeren:

  • Gibt es ein klares Modulmodell?
  • Sind Domain und Application frameworkfrei?
  • Sind Transaktionsgrenzen dokumentiert?
  • Gibt es Health, Metrics, Logs und Traces?
  • Sind Runtime-Profile dokumentiert?
  • Sind Dependency-Versionen zentral gesteuert?
  • Gibt es Architecture Tests?
  • Gibt es Start-/Smoke-Tests?
  • Gibt es Upgrade-Strategie und Supportpfad?
  • Kann ein neuer Adapter gebaut werden, ohne Fachlogik neu zu schreiben?