1. Refactoring Patterns: Kleine Schritte
Wenn Sie jemals versucht haben, „Ordnung in die Persistenzschicht zu bringen“, kennen Sie das klassische Szenario: Sie öffnen einen Service, sehen 300 Zeilen, 12 Repository-Aufrufe und irgendwo dazwischen saveAndFlush(), und dann denken Sie: „Na ja … fangen wir erstmal an, LAZY gegen EAGER auszutauschen, vielleicht hilft es ja.“ Das ist eine normale menschliche Reaktion, aber sie führt selten zu gutem Code. Wir brauchen Refactoring Patterns, damit das Gehirn aufhört, hin und her zu springen.
Ein Refactoring Pattern im Kontext von Hibernate ist eine kleine, wiederholbare Verbesserung, die das Verhalten des ORM vorhersagbarer macht. Nicht „macht alles 10-mal schneller“, nicht „beseitigt alle Fehler für immer“, sondern genau: Es reduziert den Bereich der Ungewissheit. Hibernate ist ja schon „intelligent“ (manchmal sogar zu intelligent), daher ist unsere Hauptaufgabe, dafür zu sorgen, dass es in verständlichen Grenzen intelligent ist.
Die drei Patterns sind hier gerade deshalb nützlich, weil sie drei typische Krankheiten gut behandeln.
Read-Model Split behandelt das Entity-Leakage: Sie hören auf, Entities nach außen zu geben, wenn Sie nur eine Liste anzeigen oder einen Export erstellen müssen. Die Entity bleibt das Write-Modell, und das Lesen bekommt seine eigene Datenform.
Die explizite Abfrage (explicit query) behandelt das „magische Repository“: Anstatt zu hoffen, dass findById() irgendwie „alles richtig lädt“, legen Sie direkt die Leseform und den Fetch-Plan für den Use Case fest.
Eine engere Unit of Work behandelt die overscoped transactions: Die Transaktion umfasst nicht mehr „alles Mögliche“, und Hibernate lebt nicht mehr zu lange und führt SQL zu unerwarteten Zeitpunkten aus.
Damit dies nicht als drei Slogans, sondern als Arbeitswerkzeug im Gedächtnis bleibt, halten wir eine einfache Tabelle fest – nicht als Checkliste „mach es so“, sondern als Karte „was behandeln wir womit“.
| Symptom im Code | Was fühlt sich im Produktivbetrieb meistens an | Welcher Refactoring-Schritt hilft meistens | Typisches Beispiel im Commerce Persistence Lab |
|---|---|---|---|
| PurchaseOrder | unvorhersehbare Lazy-Ladungen, LazyInitializationException oder „Warum so viele SQL’s?“ | Read-Model Split | Bestellliste / Bestellübersicht |
| findById() | und dann „läuft durch den Graphen“ | N+1, nachgelagerte Selects, seltsamer Preis für „einfaches Lesen“ | findForEditingById(), findSummaryById() |
| Eine Methode ist transaktional und macht alles: lesen, ändern, formatieren, loggen | lange Transaktionen, unnötiger Flush, Sperren, schwierige Diagnose | engere Unit of Work | Bestellungen schließen + Bericht generieren |
Zuerst ist es sinnvoll, sich jeden Schritt einzeln anzusehen, und dann zu betrachten, wie sie in einem „Vorher/Nachher“-Szenario zusammenspielen, damit klar ist: Das ist keine Theorie, sondern ganz konkrete Code-Stücke.
2. Read-Model Split: Lesen vs. Schreiben
Wenn man ein Spring Data JPA-Projekt beginnt, möchte man es am liebsten so machen: „Alles wird eine Entity, und DTOs kommen später.“ Irgendwann funktioniert das sogar. Aber mit der Zeit tritt eine unangenehme Eigenschaft zutage: Eine Entity ist ein „lebendiges“ Ding. Sie kann managed sein, ein Proxy sein, lazy Assoziationen laden und am Dirty Checking teilnehmen. Wenn Sie eine Entity dorthin geben, wo sie nicht leben sollte, lassen Sie buchstäblich die Katze auf die Straße: Ob sie zurückkommt, ist nicht sicher, aber Überraschungen bringt sie auf jeden Fall mit.
Die Essenz des Read-Model Split ist einfach: Die Entity ist das Write-Modell, also eine Datenform, die sich zum Ändern innerhalb einer Transaktion eignet. Ein Lese-Use-Case (Liste, Zusammenfassung, Export, Bericht) sollte seine eigene Form erhalten, in der Regel als DTO/Projection. Das ist keine „DDD-Religion“, sondern praktische Hygiene: So schleppen Sie keinen managed-Graphen dorthin, wo nur eine Spaltenauswahl benötigt wird.
In unserem Commerce Persistence Lab ist ein typisches Beispiel die Produktliste für das Admin-Panel. Für die Liste brauchen wir normalerweise id, sku, name, eventuell status und Preis. Aber ProductDetails, Kategoriezuordnungen, Audit usw. sind für andere Use Cases. Deshalb erstellen wir ein schmales Read-Modell.
Beispiel: Einfaches DTO für die Produktliste
Beginnen wir mit einem record (in Java 25 ist das eine sehr praktische Form für „Daten ohne überflüssige Philosophie“):
package com.example.commerce.catalog.dto;
// DTO für die Liste: nur das, was für die Tabellenausgabe nötig ist, ohne Entity-Graph und lazy Assoziationen.
public record ProductListRow(Long id, String sku, String name) {
}
Beachten Sie: Das ist kein „universelles DTO für alle Lebenslagen“. Es ist extra langweilig und kurz. Wenn ein DTO beginnt, zu „alles über das Produkt, inklusive Gedanken des Autors“ auszuwachsen, wird es zu einem Giant DTO und wiederholt das Schicksal eines Giant Entity Graphen.
Beispiel: Query-Repository, das ein DTO zurückgibt
Als nächstes erstellen wir ein query-orientiertes Repository. Seine Aufgabe ist nicht „alles CRUD“, sondern genau ein Lese-Szenario: die Zeilen für die Liste zu holen.
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
// Repository für das Lesen: Wir geben ein DTO zurück und legen damit die Datenform fest.
public interface ProductQueryRepository extends Repository<Product, Long> {
// Die Abfrage sammelt sofort die "Zeilen" für die UI: Hibernate kann nicht versehentlich über Getter Assoziationen nachladen.
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
where p.status = 'ACTIVE'
order by p.name
""")
List<ProductListRow> findActiveRows();
}
Hier sind mehrere wichtige Punkte, die genau dem Hibernate-Denken helfen.
Erstens legt die Abfrage die Datenform fest. Hibernate kann nicht „versehentlich“ ProductDetails und Kollektionen laden, weil wir keine Entity laden – wir bauen das DTO direkt.
Zweitens ist das fast immer billiger: weniger Spalten im SQL, weniger Daten im Netzwerk zwischen DB und Anwendung, weniger Objekte im Speicher, weniger Overhead durch den Persistenzkontext.
Drittens verschwindet ein Teil der zufälligen Nebeneffekte. Ein DTO ist kein Lazy-Proxy, ein DTO ist nicht „managed“. Es sind stumpfe Daten.
Beispiel: Read-Service, der KEINE Entity zurückgibt
Jetzt erstellen wir einen Leseservice. Der entscheidende Punkt ist hier nicht, „das Repository zur architektonischen Pflichtübung mit einem Service zu ummanteln“, sondern eine Grenze zu setzen: Der Read-Service gibt ein Read-Modell zurück.
package com.example.commerce.catalog.service;
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.query.ProductQueryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProductReadService {
private final ProductQueryRepository productQueryRepository;
public ProductReadService(ProductQueryRepository productQueryRepository) {
this.productQueryRepository = productQueryRepository;
}
@Transactional(readOnly = true) // Explizit markieren: Das ist ein Lesevorgang, ohne Absicht etwas zu ändern oder zu flushen.
public List<ProductListRow> listActiveProducts() {
return productQueryRepository.findActiveRows();
}
}
@Transactional(readOnly = true) ist hier nicht „weil es hübscher aussieht“. Wir haben bereits besprochen, dass der Read-Only-Modus für Hibernate die Flush-Erwartungen beeinflusst und unnötigen Dirty-Checking-Overhead reduzieren kann. Aber das Entscheidende ist nicht einmal die Optimierung, sondern die Semantik: Wir sagen explizit „das ist ein Lesevorgang“ und machen die Methode nicht zu einem versteckten Write-Szenario.
Wichtige Nuance: Eine Projection kann den Join ebenfalls weiter öffnen, als es scheint
Es ist verlockend, so etwas zu schreiben wie ProductListRow(Long id, String sku, String categoryName) und „einfach den Kategorienamen zu holen“. Aber sobald das DTO Felder aus verknüpften Entitäten enthält, haben Sie begonnen, die fetch-Form der Abfrage zu bestimmen. Das ist in Ordnung, machen Sie es sich nur bewusst.
Wenn Sie c.name hinzufügen, benötigen Sie einen join, und das SQL wird breiter. Das ist weder gut noch schlecht – es ist eine Entscheidung. Das Problem beginnt, wenn ein Entwickler denkt: „Ich habe ja ein DTO zurückgegeben, also ist alles sicher“, aber tatsächlich eine Abfrage mit einem großen join gemacht hat, der mehr Zeilen zieht und das Ergebnis aufblähen kann. Read-Model Split hebt die Notwendigkeit nicht auf, über die Form der Abfrage nachzudenken – es macht diese Form nur expliziter.
Mini-Schema: Was ändert sich beim Read-Model Split
flowchart TD
A["Service"] -->|gibt Entity zurück| B["Externer Code"]
B -->|berührt versehentlich lazy| C["SQL-Überraschungen"]
A2["Read Service"] -->|gibt DTO zurück| B2["Externer Code"]
B2 -->|DTO kann nicht nachgeladen werden| D["Vorhersagbares Verhalten"]
Wenn Sie sich eine einzige Idee aus diesem Abschnitt merken möchten, dann diese: Eine Entity ist kein „Datenformat“, sondern ein „vom ORM verwaltetes Objekt“. Daher ist es fast immer verdächtig, eine Entity für gewöhnliche Lese-Szenarien nach außen zu geben.
Giant Graph: Nicht jeder Fall wird nur durch einen Fetch-Plan geheilt
Hier ist es hilfreich, zwei ähnliche Schmerzen zu unterscheiden. Manchmal entsteht ein Giant Graph, weil der Lese-Fall mit dem Entity-Graphen bedient werden soll: Eine Liste oder Zusammenfassung braucht einfach nicht die gesamte PurchaseOrder, dann helfen Read-Model Split und eine explizite Abfrage. Aber es gibt auch eine andere Geschichte: Die Wurzel selbst ist zu breit geworden und zieht Kollektionen, Kaskaden und Lifecycle-Regeln mit sich, die nicht zusammenleben müssen.
Aggregate Boundary ist hier einfach die Grenze dessen, was wirklich atomar zusammen in einer Unit of Work geändert werden sollte. Wenn eine Bestellung in fast jedem Write-Fall ein halbes Projekt nach sich zieht, liegt das Problem nicht nur im Fetch-Plan. Sie haben vielleicht versehentlich ein riesiges Lifecycle-Stück dort geschaffen, wo engere Grenzen besser wären.
| Was sichtbar ist | Was es meistens bedeutet | Erster Schritt |
|---|---|---|
| Giant Graph wird hauptsächlich zum Lesen von Listen, Karten, Exporten benötigt | Read Overfetch, die Datenform passt nicht zum Use Case | Read-Model Split + explizite Abfrage / Fetch-Plan |
| Giant Graph taucht bei fast jeder Änderung auf, weil die Wurzel unzusammenhängende Kollektionen und Kaskaden mit sich zieht | zu breite Aggregate- / Lifecycle-Grenze | überdenken, was wirklich zusammen in einer Unit of Work leben und geändert werden sollte |
Dieser Gedanke hebt DTOs und explizite Abfragen nicht auf. Er verhindert nur, dass man jedes Problem mit demselben Fetch-Trick heilen will: Manchmal muss man das Lesen eingrenzen, und manchmal muss man ehrlich zugeben, dass das Write-Modell zu viel übernommen hat.
3. Explizite Abfragen: Repository nach Use Case
Es gibt eine Reifephase eines Projekts, in der findById() und findAll() aufhören, praktisch zu sein, und beginnen, gefährlich zu werden. Nicht, weil sie schlecht sind, sondern weil sie zu universell sind. Eine universelle Methode kann Ihren Use Case nicht kennen. Und Hibernate kann die Gedanken des Entwicklers nicht lesen (manchmal hat man zwar das Gefühl, es versucht es, aber das Ergebnis ist … wie es ist).
Eine explizite Abfrage liegt vor, wenn eine Repository-/Query-Methode nach einem konkreten Szenario benannt und implementiert ist und an derselben Stelle wichtige Entscheidungen festgehalten sind: die Selektion, benötigte Joins, der benötigte Fetch-Plan, manchmal der Lock-Modus oder die Sortierung. Also „ich lese/ändere genau das und so“, nicht „na ja, ich hole erstmal die Entity, und dann sehen wir weiter“.
Im Commerce Persistence Lab ist ein sehr typisches Beispiel das Laden einer Bestellung zur Bearbeitung. Wenn Sie orderRepository.findById(id) aufrufen und dann im Service auf order.getItems() zugreifen, landen Sie wieder bei „der Fetch wird dadurch bestimmt, wer den Getter aufgerufen hat“. Das ist der Weg zu Überraschungen.
Beispiel: Repository-Methode „zum Bearbeiten“ mit explizitem Fetch-Plan
Wir können eine Methode mit @EntityGraph (oder JPQL join fetch, wenn Sie das leichter lesbar finden) erstellen. Der Sinn liegt nicht im verwendeten Werkzeug, sondern darin, dass die Methode für den Use Case existiert.
package com.example.commerce.orders.repository;
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Für das Szenario "Bearbeiten" legen wir fest, dass customer und items benötigt werden (und nicht "später wird irgendwo ein Getter aufgerufen").
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrder> findForEditingById(Long id);
}
Was bringt eine solche Methode?
Sie macht die Ladegrenze explizit. Wenn der Use Case „Bearbeiten“ den Kunden und die Positionen erfordert, ist das am Code des Repositorys sichtbar und nicht versteckt in „irgendwo wurde ein Getter aufgerufen“.
Sie reduziert das Risiko von N+1. Wir wissen bereits, dass EAGER nicht immer eine einzige Abfrage liefert und LAZY zu nachgelagerten Selects werden kann. Hier legen Sie fest: „In diesem Szenario möchte ich genau das laden“.
Sie verbessert das Code-Review. Die Methode findForEditingById() zwingt einen direkt zu der Frage: „Warum braucht das Bearbeiten die Items?“ – und das ist eine gute Frage. Bei findById() taucht diese Frage normalerweise nicht auf, weil es ja „nur ein find“ ist.
Beispiel: Query-Repository für „Summary“ statt Entity
Manchmal brauchen Sie keine ganze Bestellung, sondern nur eine Zusammenfassung: Nummer und E-Mail des Kunden. Das ist ein klassischer Read-Model Split, aber ich nehme es hier als Beispiel für eine explizite Abfrage: Die Methode drückt explizit die Datenform und den Join aus.
package com.example.commerce.orders.query;
import com.example.commerce.orders.dto.OrderSummary;
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {
// Wichtig: Wir geben keine Entity zurück, sondern ein DTO, damit das Lesen vorhersagbar und "schmal" ist.
@Query("""
select new com.example.commerce.orders.dto.OrderSummary(o.id, o.orderNumber, c.email)
from PurchaseOrder o
join o.customer c
where o.id = :id
""")
OrderSummary findSummaryById(@Param("id") Long id);
}
Und das DTO selbst:
package com.example.commerce.orders.dto;
// Zusammenfassung einer Bestellung zum Lesen: minimale Daten, um den PurchaseOrder-Graphen nicht nach außen zu tragen.
public record OrderSummary(Long id, String orderNumber, String customerEmail) {
}
Wichtig ist, dass die Methode nicht „findSomething“ heißt, sondern den konkreten Lese-Use-Case widerspiegelt. In reifem Code sieht das langweilig aus, funktioniert aber wie eine Fahrbahnmarkierung: Sie machen weniger Fehler, weil Sie weniger Verhalten „in Gedanken dazudichten“ müssen.
„Explizite Abfrage“ – das betrifft auch die Absicht
Ein sehr häufiges Anti-Pattern in der Persistenzschicht ist, wenn Code wie ein Lesevorgang aussieht, aber tatsächlich schreibt, oder umgekehrt. Zum Beispiel gibt eine Methode List<PurchaseOrder> zurück, ändert aber zwischendurch den Status oder berührt Assoziationen, und Sie bekommen einen unerwarteten Flush. Eine explizite Abfrage hilft, Lesen und Schreiben zu trennen, weil es bei Lese-Methoden leichter ist, Disziplin zu wahren: Read-Only-Transaktion, DTO, festgelegte Datenform.
Kleines Schema: „Universell“ vs. „Explizit“
flowchart TD
S["Service"] --> R1["findById"]
R1 -->|Entity| S
S -->|irgendwann später| L["Lazy-Zugriff"]
L --> Q["SQL-Überraschungen"]
S2["Service"] --> R2["findForEditingById"]
R2 -->|Entity mit geplantem Fetch| S2
S2 --> OK["Vorhersagbare SQL-Grenze"]
Um ehrlich zu sein, der größte Vorteil von expliziten Abfragen ist, dass sie Ihnen die Notwendigkeit nehmen, „sich alles merken zu müssen“. Sie hören auf, der menschliche Cache dafür zu sein, welche Assoziationen wo benötigt werden. Der Code selbst spricht darüber.
4. Smaller Unit of Work: Die Transaktion eingrenzen
Eine lange Transaktion in der Hibernate-Welt ist selten nur eine „lange Transaktion“. Meistens ist es auch ein großer Persistenzkontext, mehr Dirty-Checking-Arbeit, eine größere Chance, einen Flush „zum falschen Zeitpunkt“ zu bekommen, eine größere Chance, eine Sperre lange zu halten, und ein größeres Risiko, versehentlich zusätzliches SQL zu erzeugen, weil jemand innerhalb der Transaktion „nur mal eben“ size() einer Kollektion aufgerufen hat. Das Ergebnis ist Code, der zwar formal korrekt ist, sich aber verhält wie ein Raum mit einer Katze und einer Tüte: Es ist zwar ruhig, aber das heißt nicht, dass es sicher ist.
Das Pattern smaller unit of work bedeutet, dass eine Transaktion nur eine zusammenhängende Geschäftsoperation umfassen sollte, nicht „Operation plus Bericht, plus Logging, plus Antwortformatierung“. Der praktischste Weg, dies zu erlernen, ist, nach Stellen zu suchen, an denen innerhalb von @Transactional Arbeit erledigt wird, die keine Transaktion erfordert: Zeichenkettenaufbau, Sortierung, Gruppierung, Serialisierung, Vorbereitung einer „hübschen Antwort“.
Im Commerce Persistence Lab ist ein gutes Beispiel das Schließen von Bestellungen und die Erstellung eines Berichts. Naiv sieht das so aus: Wir öffnen eine Transaktion, durchlaufen die Liste der IDs, ändern den Status und erstellen sofort eine Textausgabe. Formal funktioniert alles. Aber die Transaktion lebt länger als nötig, und der Persistenzkontext hält managed-Entitäten, während Sie den Text formatieren. Wenn Sie während der Berichterstellung versehentlich etwas Lazy berühren, erhalten Sie zusätzliche Abfragen. Wenn die Liste groß ist, erhalten Sie eine zusätzliche Belastung durch das Dirty Checking.
Die richtige Refaktorisierung: Die Transaktion macht nur die Mutation und gibt ein minimales Ergebnis zurück, das außerhalb der Transaktion sicher ist. Die Formatierung des Berichts lebt außerhalb, wo Hibernate nicht mehr „beteiligt“ ist.
Beispiel: Command-Service schließt Bestellungen und gibt Nummern zurück
package com.example.commerce.orders.service;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class OrderCloseCommandService {
private final OrderRepository orderRepository;
public OrderCloseCommandService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional // Hier lebt nur die Mutation: Status ändern und sofort aus der Transaktion raus.
public List<String> closeOrders(List<Long> ids) {
return orderRepository.findAllById(ids).stream()
.peek(o -> o.setStatus(OrderStatus.CLOSED)) // Bewusster Seiteneffekt: Wir aktualisieren die managed-Entität innerhalb der UoW.
.map(PurchaseOrder::getOrderNumber) // Wir geben einen einfachen Wert zurück, der außerhalb der Transaktion sicher ist.
.toList();
}
}
Der Sinn ist, dass wir einfache Werte zurückgeben, die bereits in PurchaseOrder vorhanden sind und keinen Lazy-Zugriff erfordern. Wir geben kein List<PurchaseOrder> nach außen, weil das die Tür für Entity-Leakage und zufälliges Nachladen wieder öffnen würde.
Beispiel: Separater Service erstellt den Bericht außerhalb der Transaktion
package com.example.commerce.orders.service;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderCloseReportService {
private final OrderCloseCommandService commandService;
public OrderCloseReportService(OrderCloseCommandService commandService) {
this.commandService = commandService;
}
public String closeAndBuildReport(List<Long> ids) {
List<String> numbers = commandService.closeOrders(ids); // Die Transaktion endet innerhalb von commandService.
return String.join("\n", numbers); // Die Formatierung erfolgt außerhalb: Hibernate ist hier nicht mehr "beteiligt".
}
}
Das ist genau der smaller unit of work in seiner praktischsten Form: Die Transaktion wird unmittelbar nach dem Mutationsteil geschlossen, und alles andere geschieht ohne Beteiligung von Hibernate.
Eine Nuance, an der man oft stolpert: Self-Invocation
Manchmal versucht ein Entwickler, „eine Klasse, aber zwei Methoden“ zu machen: Die äußere, nicht transaktionale, ruft die innere @Transactional auf. Erfolgt der Aufruf innerhalb derselben Klasse, kann die Transaktion aufgrund der Proxy-Mechanik von Spring nicht angewendet werden. Das haben wir bereits früher besprochen, daher erinnere ich hier nur daran: Wenn Sie Grenzen wirklich trennen wollen, erstellen Sie einen separaten Bean (wie im obigen Beispiel) oder verwenden Sie andere explizite Mechanismen. Machen Sie keine „Schrödinger-Transaktion“, die nur in der Annotation existiert.
Mini-Schema: „Breite“ vs. „Schmale“ Unit of Work
flowchart TD
A["@Transactional Methode"] --> B["Lesen"]
B --> C["Änderung"]
C --> D["Formatierung/Logging/Export"]
D --> E["Commit"]
A2["@Transactional Command"] --> C2["Änderung"]
C2 --> E2["Commit"]
E2 --> D2["Formatierung außerhalb der Transaktion"]
Betrachtet man das als Regel, klingt es etwas langweilig, aber in einem echten Projekt spart es Stunden an Diagnose: In der Transaktion halten wir nur das, was wirklich eine Transaktion erfordert.
5. Szenario „Vorher/Nachher“ im Commerce Persistence Lab
Wenn Refactoring Patterns einzeln leben, sehen sie wie drei unabhängige Ideen aus. In der Praxis arbeiten sie gut „im Verbund“, weil jedes die Schwachstelle des anderen abdeckt. Lassen Sie uns eine kleine Geschichte in der vertrauten Bestelldomäne zusammenstellen, ohne sie zu einem „lass uns die ganze Anwendung umschreiben“ ausufern zu lassen.
Stellen wir uns einen Use Case vor: „Dem Administrator eine Tabelle mit Bestellungen anzeigen und die Möglichkeit geben, ausgewählte zu schließen.“ In einer naiven Version könnte man einen einzigen Service erstellen: Er lädt PurchaseOrder als Liste, gibt die Entity nach außen, die UI liest irgendwo customer.email, dann wählt der Benutzer einige Bestellungen aus, der Service schließt sie und gibt eine Liste der geschlossenen Entities zurück.
Das Problem ist, dass diese Version auf drei „wirds schon irgendwie gehen“-Annahmen basiert: Dass niemand unnötige Lazy-Assoziationen berührt, dass die Transaktion nicht breiter ist als wir denken, und dass findById() in einer Schleife kein Problem bei größeren Datenmengen verursacht.
In der refaktorierten Version machen wir es langweilig, aber sicher.
Die Bestellliste wird als DTO ausgegeben (das ist der Read-Model Split). Das Repository für die Liste ist query-orientiert, und die Abfrage legt den Join zum Kunden genau für die E-Mail explizit fest (das ist die explizite Abfrage). Der Leseservice ist mit @Transactional(readOnly = true) gekennzeichnet und gibt eine Liste von Zeilen zurück, nicht von Entities.
Das Schließen von Bestellungen erfolgt durch einen separaten Command-Service, der innerhalb einer Transaktion den Status ändert und nur die Nummern der geschlossenen Bestellungen zurückgibt (das sind die kleinere Unit of Work + Verzicht auf Entity-Leakage am Ausgang). Wenn das Ergebnis angezeigt werden soll, erstellt eine separate Schicht außerhalb der Transaktion den Berichtstext oder ein DTO für die Antwort.
Wichtig ist, dass dies keine „architektonische Revolution“ erfordert. Wir führen keine neuen Technologien ein, ändern Hibernate nicht und schreiben das gesamte Domänenmodell nicht um. Wir machen nur die Datengrenzen explizit: Was wir lesen, was wir schreiben, wo die Transaktion beginnt und wo sie endet.
Und das hat fast immer zwei technische Effekte, die sich auch für Anfänger angenehm anfühlen.
Der erste Effekt ist, dass das SQL lesbar wird. Nicht „mir scheint, es gibt weniger Abfragen“, sondern es gibt wirklich weniger Stellen, an denen eine Abfrage „aus sich heraus“ entstehen kann. Sie lesen ein DTO – das bedeutet, unnötiges SQL wird nicht durch einen Getter entstehen.
Der zweite Effekt ist, dass der Code einfacher zu reviewen ist. Wenn Sie findForEditingById() oder findActiveRows() sehen, verstehen Sie die Absicht. Wenn Sie den Service OrderCloseCommandService sehen, verstehen Sie die Grenze der Mutation. Das ist ein sehr „nicht-magischer“ Stil, und er passt hervorragend dazu, dass Hibernate von Natur aus magisch ist. Wir balancieren sozusagen die Kräfte aus: Hibernate macht das Implizite innerhalb der Unit of Work, und wir machen die Unit of Work selbst und die Datenform explizit.
6. Typische Fehler beim Refactoring der Persistenzschicht
Fehler Nr. 1: Einen Giant Entity Graph durch ein Giant DTO ersetzen und denken, es sei besser geworden.
Manchmal hört ein Entwickler „Read-Model Split“ und beschließt: „Aha, dann machen wir halt ein DTO OrderFullDto mit 40 Feldern und leben glücklich.“ Das Problem ist, dass Sie den Gigantismus nur von der Entity ins DTO verschoben haben. Ein solches DTO wird beginnen, Joins zu ziehen, das Resultset aufzublähen, Abfragen zu verkomplizieren und sich in ein „universelles Format für alles“ zu verwandeln. Besser ist es, mehrere schmale Read-Modelle zu erstellen: ein separates für die Liste, ein separates für die Karte, ein separates für den Export. Das ist langweilig, aber stabil.
Fehler Nr. 2: Ein DTO erstellen, aber den Service belassen, der „für alle Fälle“ weiterhin die Entity zurückgibt.
Oft ist es so: Jemand hat OrderSummary hinzugefügt, aber die alte Methode getOrder() gibt trotzdem PurchaseOrder zurück, weil „braucht ja vielleicht mal jemand“. Das Entity-Leakage bleibt bestehen, und das DTO wird nicht zum Standardweg. Es ist wichtig, sich im Projekt abzustimmen: Lese-Services geben Read-Modelle zurück, Entities bleiben innerhalb der Persistenz-/Schreibschicht. Wenn „es ab und zu mal jemand braucht“, bedeutet das in der Regel „wir haben zwei verschiedene Use Cases, die wir versehentlich vermischt haben“.
Fehler Nr. 3: Versuchen, die „Unit of Work zu verkleinern“, aber Formatierungen, Sortierungen und „hübsche“ Mappings innerhalb der Transaktion zu belassen.
Die Transaktion zu verkleinern bedeutet nicht, eine @Transactional-Annotation auf eine andere Methode zu setzen und sich zu beruhigen. Es bedeutet, alles aus der Transaktion herauszuziehen, was keine Transaktion erfordert. Wenn Sie innerhalb von @Transactional JSON, CSV, riesige Zeichenketten bauen, komplexe Gruppierungen vornehmen – ist die Transaktion immer noch breit. Hibernate lebt immer noch lange. Besser ist es, minimale Daten aus der Transaktion zurückzugeben (ID, Nummern, Summen) und die „Schönheit“ außerhalb zu bauen.
Fehler Nr. 4: Explizite Abfragen schreiben, aber den Use Case nicht im Methodennamen abbilden.
Man kann eine @Query schreiben und die Methode findSomethingCool() nennen. Formal ist das eine explizite Abfrage, aber gefühlt ist es wieder Magie. Ein guter Methodenname ist Teil der Vorhersagbarkeit. findForEditingById(), findActiveRows(), findSummaryById() klingen etwas ausführlich, erklären aber die Absicht und helfen, die Methode nicht „zweckzuentfremden“.
Fehler Nr. 5: Nur den Code reparieren, aber das SQL-Trace und die Tests ignorieren.
Die Persistenzschicht ist trügerisch: Nach einem Refactoring können Tests grün bleiben, aber das SQL-Verhalten kann immer noch problematisch sein (z. B. weniger Abfragen, aber breitere Joins, oder der N+1 ist weg, aber dafür tritt ein versehentliches UPDATE auf). Daher ist es nach jedem Refactoring-Schritt wichtig, wenigstens einmal das SQL-Trace anzusehen: Wie viele Abfragen, welche Form, gibt es unnötige UPDATEs? Wir machen keinen Performance-Audit bei jedem Commit, aber „einmal draufschauen“ ist eine grundlegende Hygienemaßnahme.
GO TO FULL VERSION