Das nimmst du mit
- Wertobjekte und Aggregate sauber modellieren
- Use Case und Infrastruktur trennen
- Transaktionsgrenzen sichtbar machen
- Events zuverlässig weitergeben
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.