Das nimmst du mit
- Wertobjekte und Aggregate sauber modellieren
- Use Case und Infrastruktur trennen
- Transaktionsgrenzen sichtbar machen
- Events zuverlässig weitergeben
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.
Kapitelkompass
Die Lernroute verbindet Sprache, Domänenmodell, Ports, Transaktion und Outbox zu einem durchgängigen Bestellablauf.
Eine Bestellung wird validiert, gespeichert und gemeinsam mit einer Outbox-Nachricht festgeschrieben.
Beginne beim fachlichen Invariant. Framework und Persistenz folgen erst, wenn die Grenze klar ist.
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.
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.
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.
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.
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.
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);
}
}
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 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.
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.
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.
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.
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.
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);
}
}
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.
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.
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.
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.
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 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 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 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.
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.
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.
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();
};
}
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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()));
}
);
}
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.
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.
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.
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.
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.
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()
}
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.
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.
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 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.
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()
}
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.
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 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.
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.
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.
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.
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.
@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.
@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
}
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.
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 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.
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 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.
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.
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.
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.
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.
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());
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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?
| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
BigDecimal ohne Skala/Rundungskonzept.double für Geld.Optional als Entity-Feld.List statt fachlich benötigtem Set oder umgekehrt.Map<String,Object> als dauerhaftes
Domain-Modell.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.
Füge cancel() im Aggregate hinzu. Definiere, in welchen
Status eine Stornierung erlaubt ist. Erzeuge ein Domain Event
OrderCancelled. Passe Tests an.
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.
Ersetze die direkte Exception durch ein
DomainError.ConcurrentModification. Diskutiere, ob das im
Repository oder Application Service passieren sollte.
Simuliere ein Repository, das Order-Liste und Lines einzeln lädt. Zähle Queries. Baue danach eine Batch-Variante.
Zerlege LegacyOrderProcessor auf Papier in Validation,
Pricing, Customer Check, Limit Check und Result Mapping. Schreibe zuerst
Characterization Tests, dann extrahiere Schritt für Schritt.
| 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 |
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.
Kapitelkompass
ACID ist kein Etikett für Methoden, sondern eine Aussage darüber, welche Zustände gemeinsam erfolgreich oder gar nicht sichtbar werden.
Order und Zahlung sollen konsistent bleiben, obwohl ein nachgelagerter Dienst zeitweise ausfällt.
Eine Transaktion sollte genau die Daten umfassen, deren Invarianten gemeinsam gelten.
Zu große Transaktionen erhöhen Kopplung, Sperrdauer und Ausfallradius.
Thema: Transaktionen wirklich verstehen
Kapitel hat Transaktionen, Locking und Outbox bereits angerissen.
Kapitel geht bewusst einen Schritt zurueck und macht die Transaktion
selbst zum Lerngegenstand. Das Ziel ist nicht, eine Annotation wie
@Transactional auswendig zu kennen, sondern zu verstehen,
welche fachliche Wahrheit eine Transaktion schuetzt.
In vielen Enterprise-Projekten wird Transaktionalitaet zu frueh mit einem Framework gleichgesetzt. Dann entsteht Code, der scheinbar funktioniert, aber bei Timeouts, Broker-Ausfaellen, parallelen Requests oder Rollbacks falsche Nebenwirkungen erzeugt. Dieses Kapitel zeigt deshalb nicht nur die gute Loesung, sondern auch die schlechte Loesung.
Merksatz: Eine Transaktion ist kein Performance-Feature. Sie ist eine fachliche Konsistenzgrenze.
Eine Transaktion beantwortet die Frage: Welche Aenderungen duerfen nur gemeinsam sichtbar werden? Bei einer Bestellung kann das bedeuten: Order anlegen, Lager reservieren, Audit-Eintrag schreiben und Outbox-Nachricht vorbereiten. Wenn eine dieser Aenderungen fehlschlaegt, darf der fachliche Zustand nicht halb sichtbar werden.
Ein haeufiger Fehler ist, Transaktionen entlang technischer Klassen zu schneiden: Repository-Methode A ist transaktional, Repository-Methode B ist transaktional, Service-Methode C ruft beide auf. Das kann zufaellig funktionieren, aber die fachliche Grenze liegt dann nicht sichtbar im Use Case. Besser ist: Der Application Service macht die Use-Case-Grenze deutlich.
ASCII-Mentalmodell:
PlaceOrder Use Case
├─ Order validieren
├─ Inventory reservieren
├─ Order speichern
├─ Outbox schreiben
└─ Commit oder Rollback als eine Einheit
Die Frage ist nicht: Welche Methode bekommt eine Annotation? Die Frage ist: Welche fachliche Aussage darf nach Commit wahr sein?
ACID wird oft als Definition gelernt und danach vergessen. In Enterprise-Systemen steckt hinter jedem Buchstaben ein konkretes Risiko.
| ACID-Aspekt | Bedeutung im Bestellprozess | Typisches Risiko |
|---|---|---|
| Atomicity | Order und Outbox werden gemeinsam committed | Order existiert ohne Event oder Event ohne Order |
| Consistency | Invarianten bleiben gueltig | Order ist CONFIRMED, obwohl Inventory nicht reserviert wurde |
| Isolation | parallele Transaktionen stoeren sich nicht unerwartet | zwei Requests reservieren denselben Lagerbestand |
| Durability | Commit bleibt dauerhaft | Prozessneustart verliert bereits bestaetigte Order |
Atomicity ist im Alltag meist das erste Problem: Ein Use Case hat mehrere Schreiboperationen. Ohne klare Grenze entstehen halbe Zustaende. Isolation wird im naechsten Vertiefungsschritt ausgebaut, weil dort Lost Update, Phantom Read und Deadlock-Szenarien mehr Raum brauchen.
Wichtig: ACID gilt fuer Ressourcen, die tatsaechlich an der Transaktion teilnehmen. Ein HTTP-Aufruf, eine E-Mail, ein Kafka-Send oder ein Payment-Provider ist normalerweise nicht automatisch Teil derselben Datenbanktransaktion.
Die schlechte Loesung sieht auf den ersten Blick einfach aus: Order speichern und danach direkt ein Event senden. Genau so entstehen aber schwer auffindbare Produktionsfehler.
package com.example.txdeepdive.application;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.tx.*;
// Anti-Pattern: Dual Write - Datenbank-Transaktion und Broker-Side-Effect werden unkoordiniert gemischt.
public final class BadDualWriteOrderService {
private final TransactionManager txManager;
private final OrderRepository orders;
private final EventBroker broker;
public BadDualWriteOrderService(TransactionManager txManager, OrderRepository orders, EventBroker broker) {
this.txManager = txManager;
this.orders = orders;
this.broker = broker;
}
public void placeOrderAndCrashBeforeCommit(PlaceOrderCommand command) {
txManager.inTransaction("bad-place-order", () -> {
Order order = new Order(command.orderId(), command.customerId(), command.lines());
orders.save(order);
broker.publish("orders", "OrderPlaced:" + order.id());
// gefaehrlich: nicht rollbackfaehig
throw new DualWriteFailure("crash after broker publish but before DB commit");
}
);
}
}
Der Code sieht kurz aus, aber er hat eine gefaehrliche Eigenschaft:
Der Broker-Versand ist eine nicht rollbackfaehige Nebenwirkung. Wenn
nach dem publish() ein Fehler passiert, kann die
Datenbanktransaktion zurueckrollen. Der Broker hat das Event aber
bereits gesehen.
Produktionsfolge: Billing erzeugt vielleicht eine Rechnung, Reporting zaehlt Umsatz, Notification sendet eine Mail. Spaeter sucht jemand die Order in der Datenbank - sie existiert nicht. Genau solche Fehler sind teuer, weil sie nicht sofort im happy path auffallen.
Die bessere Loesung besteht nicht darin, Kafka oder JMS irgendwie in die Datenbanktransaktion zu zwingen. Die bessere Loesung ist, die Event-Absicht als Datenbankzustand zu speichern. Diese Idee nennt man Transactional Outbox.
package com.example.txdeepdive.application;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.outbox.OutboxMessage;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.shared.*;
import com.example.txdeepdive.tx.TransactionManager;
import java.time.Instant;
// Pattern: Application Service - orchestriert den Use Case und definiert die Transaction Boundary.
public final class SafeOrderApplicationService {
private final TransactionManager txManager;
private final OrderRepository orders;
private final InventoryPort inventory;
private final OutboxRepository outbox;
public SafeOrderApplicationService(TransactionManager txManager, OrderRepository orders, InventoryPort inventory, OutboxRepository outbox) {
this.txManager = txManager;
this.orders = orders;
this.inventory = inventory;
this.outbox = outbox;
}
public Result<PlaceOrderResult, DomainError> placeOrder(PlaceOrderCommand command) {
return txManager.inTransaction("place-order", () -> {
Order order = new Order(command.orderId(), command.customerId(), command.lines());
for (OrderLine line : command.lines()) {
Result<Void, DomainError> reserved = inventory.reserve(line.productId(), line.quantity());
if (!reserved.isOk()) {
txManager.required().setRollbackOnly();
return Result.err(reserved.errorOrNull());
}
}
order.markReserved();
orders.save(order);
OrderPlacedEvent event = new OrderPlacedEvent(order.id(), order.customerId(), order.total(), Instant.now());
outbox.save(new OutboxMessage(order.id().value(), "OrderPlaced", event.toString()));
return Result.ok(new PlaceOrderResult(order.id(), "order reserved and outbox staged"));
}
);
}
public void placeOrderAndCrashBeforeCommit(PlaceOrderCommand command) {
txManager.inTransaction("safe-place-order-crash", () -> {
Order order = new Order(command.orderId(), command.customerId(), command.lines());
orders.save(order);
outbox.save(new OutboxMessage(order.id().value(), "OrderPlaced", "payload"));
throw new RuntimeException("crash before commit");
}
);
}
}
Diese Loesung verschiebt das Problem bewusst: Der Use Case sendet nicht direkt an den Broker. Er schreibt eine Outbox-Nachricht. Wenn der Commit gelingt, gibt es Order und Outbox. Wenn der Commit fehlschlaegt, gibt es beides nicht. Der Publisher arbeitet danach asynchron und kann wiederholen.
Trade-off: Outbox macht das System nicht magisch exakt-einmal. Sie macht die Zustandsaussage sauberer: Der fachliche Zustand und die Absicht, ein Event zu senden, sind atomar verbunden.
Rollback ist kein universeller Zeitreise-Knopf. Rollback verwirft nur Aenderungen, die unter Kontrolle der Transaktion stehen. Deshalb ist es so wichtig, externe Nebenwirkungen aus der Transaktion herauszuhalten oder ueber Outbox/Inbox/idempotente Verarbeitung abzusichern.
Ein gutes mentales Modell ist: Waehrend der Transaktion werden Aenderungen vorbereitet. Beim Commit werden sie dauerhaft sichtbar. Beim Rollback werden sie verworfen. In echten Datenbanken kommen Locks, Isolation und Write-Ahead-Logs dazu. Das Grundprinzip bleibt aber: Nur transaktionale Ressourcen koennen gemeinsam committed oder zurueckgerollt werden.
Typische nicht rollbackfaehige Nebenwirkungen:
Diese Nebenwirkungen sind nicht verboten. Sie brauchen nur eine andere Architektur: Outbox, Saga, Kompensation, idempotente Consumer oder explizite Statusmodelle.
Das Code-Lab benutzt eine kleine
TransactionManager-/Transaction-Simulation,
damit Commit und Rollback sichtbar werden. Das ersetzt keine echte
Datenbank, macht aber das Prinzip sehr klar: Repositories wenden
Aenderungen nicht sofort an, sondern registrieren sie als staged
changes.
package com.example.txdeepdive.tx;
import java.util.*;
import java.util.function.Supplier;
// Pattern: Transaction Script Boundary - der Application Service fuehrt den Use Case in einer klaren Grenze aus.
public final class TransactionManager {
private final ThreadLocal<Transaction> current = new ThreadLocal<>();
private final List<String> log = new ArrayList<>();
public <T> T inTransaction(String name, Supplier<T> work) {
if (current.get() != null) throw new IllegalStateException("nested transactions are not supported in this lab");
Transaction tx = new Transaction(name);
current.set(tx);
log.add("BEGIN " + name);
try {
T result = work.get();
if (tx.rollbackOnly()) {
log.add("ROLLBACK_ONLY " + name + " staged=" + tx.size());
return result;
}
tx.commit();
log.add("COMMIT " + name + " staged=" + tx.size());
return result;
}
catch (RuntimeException ex) {
log.add("ROLLBACK " + name + " reason=" + ex.getClass().getSimpleName() + " staged=" + tx.size());
throw ex;
}
finally {
current.remove();
}
}
public Transaction required() {
Transaction tx = current.get();
if (tx == null) throw new IllegalStateException("no active transaction");
return tx;
}
public boolean active() {
return current.get() != null;
}
public List<String> log() {
return List.copyOf(log);
}
}
In einem echten Framework uebernimmt die Infrastruktur viel davon: EntityManager, Connection, TransactionManager oder Container. Trotzdem sollte man das Verhalten verstehen. Sonst verwechselt man Annotationen mit Architektur.
Warum dieses Lab sinnvoll ist:
Nicht jeder Fehler ist ein Grund, eine Exception als Steuerfluss zu
missbrauchen. InventoryUnavailable ist ein fachlicher
Fehler. Der Kunde hat mehr bestellt als verfuegbar ist. Das System ist
nicht kaputt. Ein Datenbank-Timeout ist dagegen technisch und muss
transaktional sauber abgebrochen werden.
Fachliche Fehler:
CustomerBlocked
InventoryUnavailable
DuplicateCommand
PaymentDeclined
InvalidStateTransition
Technische Fehler:
ConnectionTimeout
DeadlockVictim
ConstraintViolation durch Race Condition
SerializationFailure
BrokerUnavailable beim Publisher
Im Code-Lab werden fachliche Fehler ueber
Result<T, DomainError> ausgedrueckt. Dadurch sind
Tests klarer. Rollback bei technischen Fehlern bleibt trotzdem
wichtig.
Transaktionsdesign ist ohne Tests schwer zu vertrauen. Besonders wichtig sind nicht nur happy-path-Tests, sondern Crash- und Fehler-Szenarien.
package com.example.txdeepdive;
import com.example.txdeepdive.adapters.*;
import com.example.txdeepdive.application.*;
import com.example.txdeepdive.domain.*;
import com.example.txdeepdive.ports.*;
import com.example.txdeepdive.shared.*;
import com.example.txdeepdive.tx.*;
import java.util.List;
public final class Kapitel1TestRunner {
public static void main(String[] args) {
badDualWritePublishesEventAlthoughOrderRolledBack();
safeOutboxRollsBackOrderAndOutboxTogether();
successfulSafeUseCaseCommitsOrderInventoryAndOutbox();
System.out.println("Kapitel1_TESTS_OK");
}
static Fixtures fixtures() {
TransactionManager tx = new TransactionManager();
InMemoryDatabase db = new InMemoryDatabase();
ProductId product = new ProductId("P-42");
db.putStock(product, 5);
OrderRepository orders = new InMemoryOrderRepository(db, tx);
OutboxRepository outbox = new InMemoryOutboxRepository(db, tx);
InventoryPort inventory = new TransactionalInventoryAdapter(db, tx);
InMemoryEventBroker broker = new InMemoryEventBroker();
return new Fixtures(tx, db, product, orders, outbox, inventory, broker);
}
static PlaceOrderCommand command(ProductId product, String orderId) {
return new PlaceOrderCommand("idem-" + orderId, OrderId.of(orderId), new CustomerId("C-42"), List.of(new OrderLine(product, 2, Money.eur("12.50"))));
}
static void badDualWritePublishesEventAlthoughOrderRolledBack() {
Fixtures f = fixtures();
BadDualWriteOrderService bad = new BadDualWriteOrderService(f.tx, f.orders, f.broker);
try {
bad.placeOrderAndCrashBeforeCommit(command(f.product, "O-BAD"));
throw new AssertionError("expected failure");
}
catch (DualWriteFailure expected) {
}
assertEquals(0, f.orders.count(), "order must be rolled back");
assertEquals(1, f.broker.publishedCount(), "event was already published and cannot be rolled back");
}
static void safeOutboxRollsBackOrderAndOutboxTogether() {
Fixtures f = fixtures();
SafeOrderApplicationService safe = new SafeOrderApplicationService(f.tx, f.orders, f.inventory, f.outbox);
try {
safe.placeOrderAndCrashBeforeCommit(command(f.product, "O-SAFE-FAIL"));
throw new AssertionError("expected failure");
}
catch (RuntimeException expected) {
}
assertEquals(0, f.orders.count(), "order rolled back");
assertEquals(0, f.outbox.count(), "outbox rolled back with order");
assertEquals(0, f.broker.publishedCount(), "broker did not see uncommitted data");
}
static void successfulSafeUseCaseCommitsOrderInventoryAndOutbox() {
Fixtures f = fixtures();
SafeOrderApplicationService safe = new SafeOrderApplicationService(f.tx, f.orders, f.inventory, f.outbox);
Result<PlaceOrderResult, DomainError> result = safe.placeOrder(command(f.product, "O-OK"));
assertTrue(result.isOk(), "result should be ok");
assertEquals(1, f.orders.count(), "order committed");
assertEquals(1, f.outbox.count(), "outbox committed");
assertEquals(3, f.inventory.available(f.product), "inventory decremented after commit");
new OutboxPublisher(f.outbox, f.broker).publishPending();
assertEquals(1, f.broker.publishedCount(), "publisher sends after commit");
}
static void assertTrue(boolean value, String msg) {
if (!value) throw new AssertionError(msg);
}
static void assertEquals(int expected, int actual, String msg) {
if (expected != actual) throw new AssertionError(msg + " expected=" + expected + " actual=" + actual);
}
record Fixtures(TransactionManager tx, InMemoryDatabase db, ProductId product, OrderRepository orders, OutboxRepository outbox, InventoryPort inventory, InMemoryEventBroker broker) {
}
}
Die drei wichtigsten Tests in Kapitel sind:
Diese Tests sind bewusst ohne externe Infrastruktur gebaut. Spaeter kann man dieselben Szenarien mit echter Datenbank und Testcontainers wiederholen.
Gute Muster:
| Muster | Zweck |
|---|---|
| Application Service Boundary | Use Case definiert die Transaktion |
| Unit of Work | Aenderungen sammeln und atomar committen |
| Repository | Speicherzugriff kapseln |
| Outbox | Event-Absicht mit Fachzustand committen |
| Idempotency Key | doppelte Commands kontrollieren |
| Result Type | fachliche Fehler sichtbar modellieren |
Anti-Patterns:
| Anti-Pattern | Problem |
|---|---|
| Broker Publish vor Commit | Event kann Zustand referenzieren, der nie committed wurde |
| Long Transaction mit Remote Calls | Locks bleiben zu lange, Timeout-Risiko steigt |
| Transaktion im Controller | fachliche Grenze ist ueber Adapter verstreut |
| Repository startet heimlich eigene Transaktion | Use Case verliert Kontrolle ueber Atomaritaet |
| Exceptions fuer fachliche Normalfaelle | Tests und API-Fehler werden unklar |
| Catch-and-ignore | Rollback und Fehlerbeobachtung werden verschluckt |
In Produktion sind Transaktionen nicht nur Code. Sie haben Auswirkungen auf Betrieb, Datenbank, Monitoring und Fehleranalyse.
Worauf man achten sollte:
Ein wichtiges Betriebszeichen: Wenn die Outbox immer weiter waechst, ist nicht die Transaktion kaputt. Dann ist meist Publisher, Broker, Consumer oder Backpressure das Problem.
Vor dem naechsten Kapitel sollte man diese Fragen beantworten koennen:
Kapitelkompass
Isolation entscheidet, welche Zwischenzustände andere Transaktionen sehen und wie Konflikte behandelt werden.
Zwei Bearbeiter aktualisieren dieselbe Bestellung in unterschiedlicher Reihenfolge.
Optimistisch bei seltenen Konflikten; pessimistisch nur bei begründeter hoher Konkurrenz.
Blindes Retry kann fachliche Duplikate erzeugen und Deadlocks nur zeitlich verschieben.
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.
Inventory Reservation zeigt, warum Locking nicht abstrakt bleiben darf.
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.
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
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;
}
}
| 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 |
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.
Optimistic Locking als SQL-Mentalmodell
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
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
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");
}
}
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 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 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 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
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 |
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.
Pessimistic Locking in SQL-Denkweise
BEGIN;
SELECT quantity, version
FROM inventory
WHERE product_id = 'SKU-1'
FOR UPDATE;
-- jetzt: pruefen, reduzieren, Outbox schreiben
COMMIT;
RowLockManager.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();
}
}
}
PessimisticInventoryService.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());
}
}
}
Produktionsregel
Eine Sperre schützt den kritischen Zustand. Sie darf nicht benutzt werden, um beliebig lange Nebenwirkungen bequem in einer Transaktion zu halten.
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.
DeadlockSimulator.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();
}
}
}
| 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 |
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.
Faustregel
Retry darf eine technische Kollision überbrücken. Retry darf keine fachliche Entscheidung erzwingen, die nach dem neuen Zustand nicht mehr gültig ist.
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.
JPA @Version als Optimistic-Locking-Werkzeug
@Entity
class InventoryItemEntity {
@Id String productId;
int quantity;
@Version
long version;
}
-- Hibernate-Update enthaelt typischerweise eine Versionsbedingung.
| 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 |
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
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());
}
}
Teststrategie
Concurrency-Tests sollen nicht beweisen, dass Threads immer gleich laufen. Sie sollen kritische Reihenfolgen gezielt erzwingen und dadurch das fachliche Risiko erklären.
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.
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? |
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
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
Befehle im Lab-Ordner
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
Weiter
Kapitel vertieft Outbox, Idempotenz, Retry und Crash-Fälle. Dort wird aus Locking-Korrektheit eine robuste Integrationsstrategie.
Kapitelkompass
Die Outbox verbindet lokale Persistenz und spätere Veröffentlichung, ohne eine verteilte Transaktion vorzutäuschen.
Der Prozess stirbt nach Datenbank-Commit, aber vor der Bestätigung des Brokers.
At-least-once plus idempotente Verarbeitung ist meist robuster als vermeintliches Exactly-once.
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.
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.
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.
// 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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Typische Fehler bei Outbox-Implementierungen sind selten Syntaxfehler. Häufig sind es Betriebs- und Architekturfehler:
(status, next_attempt_at, created_at).IN_FLIGHT-Nachrichten werden nach Crash nie wieder
freigegeben.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.
Vor einer produktiven Outbox sollte man mindestens diese Fragen beantworten:
Wenn eine dieser Fragen nicht beantwortet ist, ist das System nicht fertig, sondern nur im Happy Path lauffähig.
Kapitelkompass
Produktionsreife entsteht durch Lag-Metriken, Retry-Regeln, Cleanup und ein klares Reprocessing-Verfahren.
Der Publisher arbeitet, aber alte Nachrichten bleiben liegen und das Backlog wächst.
Alarmiere auf Alter und Fortschritt, nicht nur auf die absolute Anzahl offener Einträge.
Aggressives Cleanup kann die letzte reproduzierbare Spur eines Zustellfehlers entfernen.
Thema: Produktionssicht: Monitoring, Cleanup,
Backpressure, Betrieb und Checklisten
Teilblock: 4 von 4 innerhalb Kapitel
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.
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.
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.
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:
// Pattern: Strategy - Batchgroesse wird aus Betriebszustand berechnet.
public interface BackpressurePolicy {
int batchLimit(MetricSnapshot snapshot);
}
Diese Regel ist bewusst einfach, aber realistisch:
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.
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.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.
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.
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.
Ein Runbook verhindert Panik. Es sagt nicht nur, was technisch kaputt sein könnte, sondern in welcher Reihenfolge man sicher prüft.
Beispielablauf:
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.
Outbox-Performance hängt stark von einfachen, stabilen Queries ab. Typische Publisher-Query:
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:
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.
Der Publisher ist nicht nur ein technischer Loop. Er ist ein eigener Application Service mit Regeln:
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.
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:
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.
Häufige Fehler:
status,next_attempt_at,created_at.FAILED oder DLQ blind.failedCount, nicht auf
oldestPendingAge.Merksatz: Die Outbox schützt vor Dual-Write, aber nicht vor schlechtem Betrieb.
Vor produktivem Einsatz prüfen:
oldestPendingAge messbar?Wenn eine dieser Fragen offen ist, ist die Outbox zwar ein Code-Pattern, aber noch kein produktionsreifes Betriebsmodell.
Kapitel ist damit vollständig:
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.
Kapitelkompass
Gute REST-Schnittstellen trennen Transport, Fachmodell und Fehlervertrag und bleiben unter Evolution kompatibel.
Ein Client sendet eine alte Repräsentation, während der Server ein neues optionales Feld einführt.
Additive Änderungen bevorzugen; Versionen nur bei wirklich inkompatiblen Vertragsbrüchen.
Entities direkt auszuliefern koppelt Datenbank, Security und API-Evolution aneinander.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Evolution | Optionales Feld oder neuer Link | Additiv ändern und alte Clients weiter akzeptieren |
| Pagination | Große, veränderliche Ergebnismenge | Stabilen Sortierschlüssel und Cursor bevorzugen |
| Idempotenz | Client wiederholt POST nach Timeout | Idempotency Key mit Ergebnis und Ablaufzeit speichern |
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.
// 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);
}
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.
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.
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
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.
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.
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);
}
}
Automapping ist fuer einfache CRUD-Faelle brauchbar. Bei Geld, Status, Berechtigungen, Datenschutz, Aggregates und fachlichen Fehlern sollte Mapping explizit und testbar bleiben.
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.
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;
}
}
| 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 |
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.
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"));
}
}
| 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 |
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.
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);
}
}
| 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 |
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.
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;
}
}
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.
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.
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() + "\"");
}
}
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.
GET /orders ohne Limit ist in Demos bequem und in
Produktion riskant. Daten wachsen, Sortierungen werden teuer, Responses
werden gross, und Timeouts nehmen zu.
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.
size definiert?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.
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);
}
}
}
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.
API-Tests muessen Vertragsfehler pruefen: Validation Problem, Unsupported Version, Idempotency Replay, Idempotency Conflict und ETag Conflict. Gerade diese Faelle entscheiden, ob Clients robust werden.
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);
}
}
Kapitelkompass
Authentifizierung liefert Identität; Autorisierung bewertet Kontext, Ressource, Mandant und konkrete Berechtigung.
Ein Support-Mitarbeiter darf Metadaten sehen, aber keine Zahlungsdetails eines fremden Mandanten.
Berechtigungen an fachlichen Aktionen ausrichten, nicht an zufälligen Controller-Methoden.
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.
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.
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.
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.
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.
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.
Eine robuste Validierung prueft mindestens:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 |
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.
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?
public record AuditEvent(
Instant occurredAt,
String correlationId,
String actor,
String action,
String resource,
String decision,
String reason,
String remoteAddress
) {
}
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.
| 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 |
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.
Aus Kapitel kommt der API-Vertrag. Kapitel fuegt die Sicherheitsentscheidung hinzu. Der Request-Flow wird damit:
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.
if (role.equals("ADMIN") || role.equals("SUPER_USER")) { ... }
Problem: schwer testbar, schwer refactorbar, keine zentrale Permission-Sicht.
Wenn Messaging-Consumer oder Batchjobs dieselbe Use-Case-Logik ohne Controller ausfuehren, werden Regeln umgangen.
Ein Angreifer kann IDs raten und durch 403 erkennen, dass eine Ressource existiert.
Claims muessen zu Issuer, Audience, Ablaufzeit, Tenant und Signatur passen. Sonst akzeptiert das System fremde oder abgelaufene Tokens.
Auch verweigerte Zugriffe sind sicherheitsrelevant.
Security braucht eigene Tests. Controller-Tests allein sind zu grob. Gute Security-Tests pruefen Policies, Fehlerabbildung, Audit und Service-Scopes.
Im Lab enthalten:
- 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
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.
[ ] 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?
Der wichtigste Pfad im Lab ist:
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.
OrderCancelPolicy: Kunde darf eigene
Bestellung nur stornieren, wenn Status NEW oder
CONFIRMED ist.TenantPolicy hinzu, die Tenant-Pruefungen
zentralisiert.clientId fuer Service-Accounts.TokenValidationException-Details in Problem Details
auftauchen.SUPPORT_READ_ONLY und
SUPPORT_MANAGER als getrennte Rollen.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.
Kapitelkompass
Messaging entkoppelt Zeit und Verfügbarkeit, verlangt dafür klare Event-Semantik, Partitionierung und Schema-Evolution.
OrderCreated muss pro Bestellung geordnet, aber über Bestellungen parallel verarbeitet werden.
Partitioniere nach der fachlichen Einheit, für die Reihenfolge tatsächlich garantiert sein muss.
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.
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.
Schlechte Enterprise-Lösung:
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.
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:
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.
Commands, Events und Queries sind keine Namenskonventionen, sondern unterschiedliche fachliche Aussagen.
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:
// klingt wie Tatsache, ist aber eigentlich eine Anweisung an Billing
public record InvoiceShouldBeGeneratedEvent(String orderId) {
}
Besser:
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.
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:
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.
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 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?
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.
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.
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.
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.
Schlecht:
String partitionKey = UUID.randomUUID().toString();
Besser:
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.
Ein Event ohne Envelope ist im Betrieb schwer zu verstehen. Der Envelope trennt fachliche Payload von technischen und organisatorischen Metadaten.
Wichtige Felder:
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.
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.
Kompatible Änderung:
// 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:
// 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.
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.
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.
Ein Consumer ist kein
forEach(event -> handle(event)). Er ist eine kleine
State Machine mit technischen und fachlichen Zuständen.
Typischer Ablauf:
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.
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:
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.
Die Registry im Lab ist klein, aber fachlich wichtig. Sie verhindert, dass ein Consumer ungeprüft unbekannte Versionen verarbeitet.
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.
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.
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.
Typische Fehler in Enterprise-Projekten:
OrderChanged als einziges Event nutzenSchlechtes Beispiel:
public record DbOrderRowChanged(Map<String, Object> row) {
}
Besser:
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.
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:
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.
Vor einem produktiven Messaging-Design sollte diese Liste beantwortet sein:
Wenn diese Fragen offen sind, ist das System nicht tief verstanden, auch wenn der Code bereits Nachrichten sendet.
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:
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:
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.
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.
Ein Event-Schema muss über ein Migrationsfenster betrachtet werden. Publisher und Consumer werden selten exakt gleichzeitig deployt. Deshalb braucht man Phasen:
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:
// Publisher wechselt sofort von V1 zu V2, Consumer versteht nur V1.
broker.publish(new OrderPlacedV2(orderId, amount, currency, taxRate));
Besser:
// 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.
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:
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.
Offset-Commit ist einer der wichtigsten Punkte im Consumer-Design.
Schlecht:
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:
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.
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:
OrderPlaced als JMS-Arbeitsauftrag behandeln,
obwohl mehrere unabhängige Systeme dieselbe Tatsache brauchen.
Nicht gut:
GenerateInvoiceCommand als Kafka-Domänen-Event publizieren,
obwohl es einen klaren Empfänger und eine Ausführungsabsicht hat.
Gute Eventnamen für die Order/Billing/Payment-Domäne:
OrderPlaced
OrderAccepted
OrderRejected
InventoryReserved
InventoryReservationFailed
PaymentAuthorized
PaymentDeclined
InvoiceGenerated
InvoiceGenerationFailed
OrderCancelled
RefundRequested
RefundCompleted
Vorsichtige oder schlechte Namen:
OrderChanged
OrderUpdated
ProcessOrderEvent
DoBillingEvent
PaymentStatusChanged
DataSynced
Warum? Die schlechten Namen beschreiben entweder zu wenig Fachlichkeit oder eine technische Aktivität statt einer fachlichen Tatsache.
Ein Event sollte genug enthalten, damit Consumer ihre Aufgabe erfüllen können, aber nicht das komplette interne Aggregat dumpen.
Zu wenig:
public record OrderPlaced(String orderId) {
}
Jetzt müssen Consumer eventuell den Order-Service synchron nachladen. Dadurch entsteht neue Kopplung.
Zu viel:
public record OrderPlaced(OrderEntityJpaDump dump) {
}
Jetzt wird die interne Datenstruktur zum öffentlichen Vertrag.
Besser:
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.
Ein echter Messaging-Testkatalog enthält mehr als einen Happy Path.
- 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.
Vor Produktion müssen mindestens diese Punkte geklärt sein:
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.
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.
Kapitelkompass
Retry, DLQ und Reprocessing brauchen Klassifikation, Begrenzung und eine nachvollziehbare Betriebsentscheidung.
Eine Nachricht scheitert wegen Timeout; eine andere wegen ungültiger fachlicher Daten.
Nur transiente Fehler automatisch erneut versuchen; permanente Fehler sichtbar isolieren.
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.
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.
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.
Ein Consumer ist nicht nur fachlicher Code. Er ist eine kleine Betriebsmaschine:
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 |
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.
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.
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.
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.
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.
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.
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:
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.
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.
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:
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.
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.
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.
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:
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.
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();
}
}
Ein hoher Lag kann verschiedene Ursachen haben:
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.
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.
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.
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.
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:
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.
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.";
}
}
| 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 |
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.
DefaultErrorClassifier, sodass
HTTP-Statuscodes getrennt klassifiziert werden.RetryPolicy, sodass bestimmte Aggregate-IDs
langsamer wiederholt werden.ReprocessingService, sodass nur Events eines
Typs reprocessed werden.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.
Kapitelkompass
Produktionsbetrieb verbindet Lag, Durchsatz, Downstream-Kapazität und kontrollierte Lastaufnahme.
Der Consumer skaliert hoch, aber Datenbankpool und Fremd-API bleiben unverändert begrenzt.
Concurrency am langsamsten Downstream ausrichten und mit Bulkhead sowie Rate Limit schützen.
Mehr Consumer erhöhen ohne Downstream-Budget oft nur Timeouts, Retries und Lag.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Lag steigt, CPU frei | Downstream oder Partition blockiert | Pool, Remote-Latenz und Head-of-line Blocking prüfen |
| Lag steigt, CPU voll | Verarbeitung CPU-limitiert | Profilen, Partitionen und Instanzbudget gemeinsam bewerten |
| Retry-Rate steigt | Fehler verstärkt Last | Retry-Budget senken, Circuit Breaker oder Pause einsetzen |
Messaging-Betrieb, Monitoring, Lag-SLO, Backpressure, Alerting und Runbook Deep Dive.
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.
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.
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.
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.
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.
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.
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.
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.
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);
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Kapitelkompass
Characterization Tests beschreiben das tatsächliche Verhalten eines Legacy-Systems, auch wenn es fachlich unbequem ist.
Ein alter Order-Prozessor mischt Preis, Steuer, Zahlung und Seiteneffekte in einer Methode.
Zuerst kritische Pfade und Seiteneffekte charakterisieren, nicht jede Zeile konservieren.
Ein Test auf interne Aufrufreihenfolge friert Struktur statt fachliches Verhalten ein.
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.
extract method und extract class
anwenden.Legacy-Code -> Characterization Tests -> kleine Refactoring-Schritte -> gleiche Tests -> Architektur verbessern
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:
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.
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:
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.
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:
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:
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.
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.
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:
Diese Szenarien prüfen nicht nur Rückgabewerte. Sie prüfen auch Reihenfolge und Nebenwirkungen. Genau dort entstehen viele Legacy-Fehler.
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:
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:
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);
}
}
}
Characterization Tests frieren Verhalten ein. Das ist Absicht, aber es hat eine Nebenwirkung: Sie konservieren auch historisches Fehlverhalten. Beispiel in der Monster-Methode:
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.
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.
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.
Ein Snapshot, der jeden internen Logeintrag prüft, verhindert Refactoring. Man sollte fachliche Signale prüfen, nicht Implementierungsrauschen.
Das klingt gut, ist aber gefährlich. Wer beim Refactoring fachliche Bugs ändert, braucht Migrationsentscheidung, Datenkorrektur und Kommunikation. Sonst ist es kein Refactoring mehr.
Wenn jedes Szenario 40 Zeilen Testdaten kopiert, versteht niemand mehr den Unterschied. Ein Szenario-Katalog hält die Fachsprache sichtbar.
Die Methode ist schwer zu refactoren, weil die Reihenfolge fachlich relevant ist:
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:
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.
Kapitel ist nicht das Ende, sondern das Sicherheitsnetz für die nächsten Vertiefungen. Kapitel kann danach die ersten Verantwortungen extrahieren:
Aber jede Extraktion muss die bestehenden Snapshots weiter bestehen. Sobald ein Snapshot bricht, muss man entscheiden:
Diese Disziplin macht aus chaotischem Legacy-Code eine kontrollierte Modernisierung.
Vor dem ersten produktionsnahen Refactoring sollten diese Punkte erfüllt sein:
[ ] 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.
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.
| 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.
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.
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.
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.
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.
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:
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.
Diese Aufgaben sind bewusst praxisnah formuliert. Sie sollen nicht nur Wissen abfragen, sondern dich zwingen, das Sicherheitsnetz zu benutzen.
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.
Entscheide, ob der Snapshot die Rechnungsnummer enthalten soll. Argumentiere aus Sicht von Refactoring-Sicherheit und Teststabilität.
Die gemischte Steuerberechnung nutzt einen vereinfachten Satz. Dokumentiere, ob du das als Bug, Legacy-Vertrag oder offene Fachfrage einstufst.
Markiere im Code einen Block, der ohne Nebenwirkung extrahiert werden kann. Führe noch keine Architekturänderung durch. Beschreibe, warum dieser Block sicher ist.
Warum ist es gefährlich, gateway.authorizePayment(...)
vor die Lagerreservierung zu verschieben? Beschreibe mindestens drei
mögliche Produktionsfolgen.
Kapitel macht Verhalten sichtbar. Er löst noch nicht die Architektur. Das ist Absicht.
Noch nicht gelöst:
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.
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 |
Kapitelkompass
Sichere Extraktion trennt fachliche Entscheidungen von Orchestrierung und Infrastruktur, ohne alles gleichzeitig neu zu bauen.
Pricing, Tax und Shipping werden aus einem Legacy-Prozessor in eigene Dienste verschoben.
Mit deterministischer Logik beginnen; I/O und Transaktionsgrenzen später verschieben.
Eine neue Service-Klasse ohne eigene Verantwortung ist nur der alte Monolith in mehr Dateien.
Legacy-Refactoring-Vertiefung: Kapitel.
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.
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.
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:
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:
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 |
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:
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.
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:
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:
public interface DiscountPolicy {
Money discountFor(PricingContext context);
String name();
}
Die einzelnen Regeln sind danach klein und gezielt testbar:
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.
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.
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.
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:
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.
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.
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.
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:
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.
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:
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:
Übungen:
BlackFridayDiscountPolicy
hinzu, ohne PricingEngine grundlegend umzubauen.TaxCalculator oder in eine Strategy
gehört.ShippingCalculator keinen PaymentPort
importieren darf.Produktionscheckliste:
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.
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();
}
}
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();
}
}
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);
}
}
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);
}
}
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");
}
}
Kapitelkompass
Feature Flags und Shadow Traffic reduzieren Cutover-Risiken, wenn Abweichungen erklärt und Zustände kontrolliert werden.
Alt- und Neusystem berechnen parallel denselben Auftrag, aber nur ein Pfad darf schreiben.
Beobachtung zuerst, Teiltraffic danach, vollständiger Cutover erst bei stabilen Gates.
Dauerhafte Flags vervielfachen Zustände und werden selbst zu Legacy.
Thema: Schrittweises Vorher/Nachher-Refactoring mit Method Object, Feature Flag und 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:
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.
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.
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:
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:
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;
}
}
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.
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.
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 |
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);
}
}
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.
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.
Parallelbetrieb bedeutet nicht blind beide Pfade laufen lassen. Man braucht eine klare Diff-Klassifikation.
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:
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.
Parity-Events dürfen keine sensiblen Daten ungefiltert loggen. Kundendaten, Zahlungsdaten und personenbezogene Felder müssen maskiert oder nur als technische Referenzen gespeichert werden.
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.
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();
}
}
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;
}
}
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");
};
}
}
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.
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.
package com.example.enterprise.Kapitel;
// Pattern: Feature Toggle - Rollout-Entscheidung ist konfigurierbar und nicht im Fachcode versteckt.
public interface FeatureToggleService {
ProcessingMode modeFor(CustomerProfile customer);
}
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);
}
}
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.
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.
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.
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);
}
}
}
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.
LEGACY: Ausgangszustand.SHADOW: Parity sammeln.NEW_WITH_FALLBACK: neuer Code liefert Antwort, Legacy
bleibt Rettungspfad.NEW: neuer Pfad ist primär.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.
Alles wird auf einmal schöner gemacht. Danach ist unklar, welche Änderung einen Fehler verursacht hat.
Der neue Pfad sendet echte E-Mails, bucht echtes Payment oder erzeugt echte externe Tickets. Das macht aus einem Vergleich einen Produktionsfehler.
Niemand weiß, wann ein Flag entfernt wird. Nach sechs Monaten ist das System komplexer als vorher.
Das Team sammelt Unterschiede, klassifiziert sie aber nicht. Shadow Mode wird dann zur Beruhigung, nicht zur Qualitätssicherung.
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.
Vor dem Umschalten auf NEW_WITH_FALLBACK:
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.
Kapitelkompass
Legacy darf erst verschwinden, wenn Traffic, Daten, Betrieb und Rollback gegen explizite Gates geprüft wurden.
Das Neusystem bedient 100 Prozent Traffic, doch ein Batchjob liest noch aus der alten Datenbank.
Code, Daten und Infrastruktur getrennt abbauen und jede Stufe mit Beobachtungszeit versehen.
Zu frühes Entfernen zerstört Rollback, Audit-Spuren oder versteckte Verbraucher.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Traffic Gate | Kein relevanter Legacy-Traffic | Nachweis über Metriken und bekannte Verbraucher |
| Data Gate | Migration vollständig und konsistent | Counts, Checksummen und Stichproben vergleichen |
| Rollback Gate | Rückweg getestet und befristet | Verantwortung, Zeitfenster und Datenrichtung dokumentieren |
Kapitel schliesst die Legacy-Refactoring-Vertiefung ab: alte Pfade entfernen, Flags bereinigen, Daten-/Schema-Cleanup planen und den finalen Cutover pruefbar machen.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
Eine sichere Reihenfolge ist: keine neuen Aufrufe erlauben, Fallback deaktivieren, Legacy read-only setzen, Beobachtungsfenster abwarten, Architekturregel aktivieren, Klasse entfernen, Testdaten bereinigen, Dokumentation aktualisieren.
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.
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.
var report = new DataMigrationVerifier().verify(legacyRows, targetRows);
if (!report.ready()) {
throw new IllegalStateException("Migration blockiert: " + report.problems());
}
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.
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.
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?
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.
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?
Die StranglerOrderFacade kapselt Routing und
Parallelbetrieb. Aufrufer kennen nur process. Damit bleibt
die Migration intern steuerbar, ohne jeden Controller, Batchjob oder
Consumer mehrfach anzupassen.
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.
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.
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.
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.
var decision = new RemovalGuard().canRemove(plan, gates, remainingFlagReferences);
assertFalse(decision.allowed(), "cleanup must be blocked while flag is referenced");
Alle kritischen Business-Szenarien sind abgenommen, historische Sonderfaelle sind erklaert, Parity-Diffs sind entweder geloest oder bewusst akzeptiert.
Build, Tests, Architekturregeln, Linkchecks, Runbooks, Monitoring und Deployment-Pipeline sind aktualisiert. Legacy-Abhaengigkeiten sind nicht nur ungenutzt, sondern entfernt.
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.
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.
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.
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.
Kapitelkompass
Heap, GC, CPU, Threads und Container müssen als korrelierter Snapshot gelesen werden; eine Einzelmetrik beweist keine Ursache.
p95 steigt, Heap liegt bei 88 Prozent und die Allokationsrate erreicht 240 MiB/s.
Erst Evidenz mit JFR, GC-Log, Thread Dump oder NMT sichern; danach genau eine Variable ändern.
Mehr Heap oder ein anderer Collector kann Symptome verschieben und Container-Restarts verschärfen.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Heap hoch, RSS passend | Java-Heap oder Retention | JFR Allocation und Heap nach GC untersuchen |
| Heap moderat, RSS hoch | Native Memory oder Thread-Stacks | NMT, Direct Buffers und Threadzahl prüfen |
| CPU hoch, wenig Blocking | Hot Path oder GC-Arbeit | Execution Profile und GC-Zeit korrelieren |
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.
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.
// 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.
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.
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:
// 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?
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.
Ein Leak entsteht häufig durch statische Collections, Caches ohne Begrenzung, ThreadLocals, Listener, Scheduler, Classloader oder falsch gehaltene Session-Daten.
// 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.
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?
// 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.
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.
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.
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.
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.
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
long used = memory.getHeapMemoryUsage().getUsed();
Im Code-Lab wird genau das genutzt: ohne externe Library, damit die Konzepte sichtbar bleiben.
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?
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.
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?
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”.
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.
Diese Checkliste verhindert Aktionismus.
Kapitelkompass
Virtual Threads vereinfachen blockierenden Code, aber Datenbank, Socket, CPU und Fremdsystem bleiben begrenzte Ressourcen.
10.000 Tasks starten, aber der Connection Pool besitzt nur 40 Verbindungen.
Virtual Threads für wartelastige Arbeit nutzen und jeden knappen Downstream separat begrenzen.
Unbegrenzte Parallelität erzeugt Warteschlangen, Timeouts und Speicherlast statt Durchsatz.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Viele wartende Tasks | I/O-bound | Virtual Threads plus Downstream-Bulkhead |
| CPU dauerhaft gesättigt | CPU-bound | Parallelität am Core-Budget begrenzen |
| Pinned Threads | synchronized oder native Blockade | JFR-Pinning analysieren und kritischen Abschnitt verkürzen |
Thema: Virtual Threads, Bulkheads, Connection-Pool-Grenzen, CPU vs I/O, Rate Limits und Produktions-Performance.
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.
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:
Virtual Threads = ich kann alles ohne Grenze parallelisieren
Besseres Mentalmodell:
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.
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.
Die naive Lösung sieht attraktiv aus: jede Teilinformation wird parallel geladen. In kleinen Tests ist sie schnell. In Produktion kann sie Downstreams überfahren.
// 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.
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.
// 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.
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?
// 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?“
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.
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.
// 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.
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.
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.
// 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.
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
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.
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.
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.
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“.
Kapitelkompass
Spring Boot, Jakarta EE, Quarkus und Micronaut unterscheiden sich nicht nur in APIs, sondern in Build, Startzeit, Ökosystem und Betrieb.
Ein Team benötigt schnelle Skalierung, bekannte Libraries und langfristig verfügbare Betriebskenntnisse.
Nach Team, Plattform, Libraries und SLOs entscheiden; Benchmarks allein reichen nicht.
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?
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:
@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.
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:
Wichtig: Der fachliche Kern weiss nicht, ob er von Spring, Jakarta, Quarkus, Micronaut, CLI, Batch oder Test aufgerufen wurde.
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.
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.
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:
@Transactional nur an klaren
Application-Service-Grenzen nutzen.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:
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:
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.
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.
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());
}
);
}
}
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 |
Framework-Tests sind oft langsam, wenn man alles mit voller Runtime startet. Das ist nicht noetig. Ein gutes Testmodell trennt:
Im Code-Lab wird derselbe Use Case ueber vier Adapter getestet. Das beweist: Frameworks duerfen unterschiedlich aussehen, aber fachliche Ergebnisse muessen gleich bleiben.
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);
}
}
Die haeufigsten Anti-Patterns in Enterprise-Java-Projekten:
@Transactional an jedem Service, ohne
Use-Case-Grenze.Gegenmittel:
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:
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 |
Vor produktiver Framework-Entscheidung klaeren: