CodeGym /Kurse /Hibernate deep-dive /Entity Leakage und Giant Graphs

Entity Leakage und Giant Graphs

Hibernate deep-dive
Level 29, Lektion 0
Verfügbar

1. Anti-Pattern und die Natur der Entity

Ein Anti-Pattern im Persistenzcode ist nicht, dass „der Compiler meckert“ oder dass „Tests rot sind“. Es ist eher ein Stil, der heute bequem erscheint, aber morgen Hibernate in ein Roulettespiel verwandelt: SQL geschieht „plötzlich“, Entity-Graphen blähen sich „von selbst“ auf, und eine einfache Änderung an einer Stelle bricht die Performance an einer anderen. Und ja, das Heimtückischste: Solcher Code sieht oft ordentlich aus und besteht sogar ein Code-Review, wenn man nur auf die Java-Zeilen schaut, nicht darauf, welches SQL tatsächlich ausgeführt wird.

Es ist wichtig, die Stimmung zu verstehen: Ein Anti-Pattern ist kein Vorwurf an den Entwickler, eine „Sünde“ begangen zu haben. Es ist eine Diagnose, dass das System die Kontrolle verloren hat. In der Persistenzschicht schmerzt das besonders, weil ORM selbst implizit ist: Es hat flush, Proxys, Dirty Checking, Kaskaden und tut vieles „für Sie“. Wenn man darauf noch architektonische Implizitheit legt, entsteht der Effekt einer „doppelten Magie“. Magie auf Magie – und am Ende sind Sie nicht in einer Zauberwelt, sondern im Support.

Wir haben bereits gesehen, dass LazyInitializationException, N+1, unerwartete UPDATE und seltsamer flush fast nie vom Himmel fallen. Normalerweise gibt es einen wiederkehrenden Codestil, der das Problem bereits vorbereitet. Daher ist es nützlich, Persistenzcode nicht nur mit den Augen „funktioniert / funktioniert nicht“ zu betrachten, sondern mit den Augen eines Reviews: Wo genau verliert der Use Case die Kontrolle über die Datenform und wo später der SQL abfeuert.

Solche Anti-Patterns sollte man pragmatisch sehen: Sie sind Signale, dass die Grenzen des Use Case verschwimmen und damit auch die Verantwortung für die Datenform. Der grundlegendste und häufigste Fall ist, wenn eine Entity nicht dort lebt, wo sie hingehört.

Entity ist nicht „einfach ein Objekt mit Feldern“

Eine Entity in Hibernate ist kein „Datencontainer“, der bedenkenlos überall hin übergeben werden kann wie ein record oder ein gewöhnliches DTO. Eine Entity ist ein Objekt, das mit den ORM-Mechanismen verbunden ist: Es kann managed innerhalb eines Persistenzkontexts sein, nach Transaktionsende detached werden, und seine Assoziationen können Proxy-Objekte und Persistent-Collections sein. Und das bedeutet eine einfache, aber unangenehme Wahrheit: Ein Getter-Aufruf kann eine SQL-Operation sein, und eine Feldänderung kann zu einem UPDATE führen (selbst wenn Sie save() nicht aufgerufen haben).

Auf Projektebene ist dies im Commerce Persistence Lab besonders gut an Bestellungen zu sehen. Eine Bestellung (PurchaseOrder) ist fast immer mit einem Kunden (Customer) und Positionen (OrderItem) verknüpft. In der Domäne sieht das nach „na klar“ aus, aber für Hibernate ist das bereits ein Graph: mindestens zwei Assoziationen, beide meist LAZY, um nicht die Bestellliste im Stil von „hol mir das ganze Internet“ zu machen.

import jakarta.persistence.*;
import java.util.Set;

@Entity
class PurchaseOrder {

    // Den Kunden muss man normalerweise nicht bei jedem Lesen der Bestellung laden, daher LAZY
    // Wichtig: Der Zugriff auf customer kann SQL auslösen, wenn der Proxy noch nicht initialisiert ist
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    // Bestellpositionen sind eine typische Quelle für zufällige Abfragen (size(), Iteration, toString() usw.)
    // Daher standardmäßig ebenfalls LAZY
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private Set<OrderItem> items;
}

Solange Sie mit einem solchen Objekt innerhalb einer Transaktion arbeiten, kann Hibernate bei Bedarf nachladen. Aber sobald Sie die Entity nach außen geben und als „universelles Leseobjekt“ verwenden, verlagern Sie die Entscheidung „was und wann geladen wird“ an eine zufällige Stelle: in die String-Formatierung, ins Log, in den Mapper, in die Serialisierung, in den Debugger. Und genau hier beginnt das nächste Thema – Entity Leakage.

2. Entity Leakage: Undichtigkeiten und unvorhersehbares SQL

Entity Leakage ist die Situation, in der eine Entity die Grenzen der Schicht verlässt, in der sie leben sollte (Persistenz/Schreiblogik), und zu einem universellen Datenträger wird: „Nehmen wir die Entity, später sehen wir weiter“. Klingt sparsam: „Wir vermehren keine DTOs“. In der Praxis bedeutet es, dass die Datenform nicht mehr Teil des Use Case ist, sondern zu einem Nebeneffekt der Getter wird, die jemand später aufruft.

Hier ist ein Beispiel, das auf den ersten Blick anständig aussieht. Ein Service liest eine Bestellung und gibt sie an den aufrufenden Code weiter. Transaktion read-only, alles „hübsch“.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class OrderReadService {

    @Transactional(readOnly = true)
    public PurchaseOrder getOrder(Long id) {
        // Geben die Entity nach außen: Jeder kann später Lazy-Felder anfassen und SQL „an unerwarteter Stelle“ bekommen
        return orderRepository.findById(id).orElseThrow();
    }
}

Das Problem ist nicht, dass die Methode syntaktisch „falsch“ wäre. Das Problem ist, dass jetzt jeder externe Code die Entity erhält und damit alles machen kann: customer lesen, über items iterieren, bei jedem Item ins product steigen, toString() aufrufen, ins Log legen, als JSON serialisieren – und jeder dieser Schritte kann eine SQL-Operation werden.

Und hier beginnt das Heimtückischste: Externer Code sieht normalerweise nicht aus wie „Arbeit mit der Datenbank“. Er sieht aus wie eine Zeile, die einfach etwas ausgibt.

// Externer Code „bildet einfach einen String“, kann aber tatsächlich das Laden der Collection auslösen
String text = order.getOrderNumber() + " / items=" + order.getItems().size();
// Potenziell: SELECT ... FROM order_item WHERE order_id = ?

Die Zeile ist harmlos wie ein Kätzchen. Aber wenn items eine Lazy-Collection ist, wird size() zum Auslöser der Initialisierung. Und damit geht SQL nicht in der „Datenschicht“ ab, sondern an einer Stelle, die überhaupt nicht ahnt, dass sie jetzt mit der Datenbank arbeitet.

Um das architektonische Problem zu fühlen, ist es nützlich, dies als Verantwortungsfluss darzustellen:

flowchart LR
    A["Externer Code
(Logging / Formatierung / Mapping)"] -->|ruft Getter auf| B["Entity
PurchaseOrder"] B -->|lazy init| C["Hibernate Session / EntityManager"] C --> D[(PostgreSQL)]

Sobald die Entity „ausgelaufen“ ist, wird Hibernate zu einem versteckten Co-Autor Ihres Codes. Und versteckte Co-Autoren sind immer ein Risiko. Sie helfen zwar manchmal, aber meistens kommen sie nachts und ändern das Ergebnis ohne Vorwarnung.

Ein weiterer unangenehmer Effekt des Leakage: Externer Code kann beginnen, die Entity „der Bequemlichkeit halber“ zu ändern. Zum Beispiel beschließt jemand, vor der Ausgabe ein Feld „umzubenennen“, einen berechneten Wert zu setzen, den Status für die Darstellung zu korrigieren. Wenn die Entity zu diesem Zeitpunkt noch managed ist (oder die Transaktion noch offen ist), kann Hibernate ein UPDATE ausführen. Und Sie erhalten das Szenario „Warum hat sich das Feld in der Datenbank geändert, obwohl ich nur die Antwort aufgebaut habe?“. Das ist genau die Klasse von unbeabsichtigten Updates, die wir im Modul über Dirty Checking besprochen haben – nur dass es diesmal nicht vom Mapper im Service kommt, sondern von „zufälligem“ Code außerhalb.

3. Giant Entity Graphs: „Ein Modell für alles“

Giant Entity Graph ist keine separate Annotation und kein „spezieller Hibernate-Modus“. Es ist die Folge davon, dass Entity zur universellen Read-Modell gemacht wurde. Wie passiert das normalerweise? Sehr allmählich und sehr menschlich: Zuerst haben Sie eine Bestellliste, dann „zeigen wir in der Liste doch auch die E-Mail des Kunden“, dann „und die Anzahl der Positionen“, dann „und den Namen des ersten Artikels“, dann „und die Lieferadresse“, und dann möchte jemand einen CSV-Export „mit allem“. In diesem Moment stellen Sie fest, dass ein einziges Entity-Modell versucht, inkompatible Szenarien zu bedienen.

Es ist nützlich, eine einfache Tabelle im Kopf zu behalten. Sie sagt nicht „wie es richtig ist“, sondern zeigt, dass verschiedene Use Cases unterschiedliche Datenformen erfordern, und das ist normal.

Use Case Was tatsächlich angezeigt werden muss Warum „gesamte Entity zurückgeben“ verdächtig ist
Bestellliste im Adminbereich orderNumber, status, createdAt, customerEmail Bestellpositionen und Produkte sind fast immer überflüssig
Details einer Bestellung order + items, manchmal product in items Dieselbe Methode für die Liste zu verwenden, ist teuer
Export/Bericht fester Satz von Feldern, oft Aggregate Der Entity-Graph ist kein Berichtsmodell; er wird „Überflüssiges nachziehen“
Bearbeitung verwaltetes Aggregat für eine bestimmte Operation Wenn die Bearbeitung „alles“ öffnet, erhalten Sie eine aufgeblähte Unit of Work

Mit Giant Graphs verbindet sich eine weitere unangenehme Beobachtung: Wenn die Datenform „am Ort der Verwendung“ bestimmt wird, versuchen Entwickler, „vorzusorgen“ und im Voraus alles zu laden, damit niemand auf Lazy stolpert. Das fühlt sich wie Fürsorge für die Kollegen an, endet aber damit, dass das Laden unvorhersehbar und schwer wird und jede Änderung die Performance zerstört.

In unserer Domäne sieht ein Giant Graph fast aus wie eine U-Bahn-Karte, bei der Sie versehentlich ein Ticket „für alle Stationen der Welt“ gekauft haben. Eine Bestellung kann den Kunden, die Adressen des Kunden, die Bestellpositionen, die Produkte in den Positionen, die Produktdetails, die Produktkategorien über eine Link-Entity nachziehen … und all das wird plötzlich „potenziell benötigt“, weil die Entity nach außen durchgesickert ist:

flowchart TD
    O[PurchaseOrder] --> C[Customer]
    C --> CA[CustomerAddress]
    O --> I[OrderItem]
    I --> P[Product]
    P --> PD[ProductDetails]
    P --> PCA[ProductCategoryAssignment]
    PCA --> CAT[Category]

Ein Giant Graph bedeutet nicht unbedingt „alles wird immer geladen“. Es ist schlimmer: Es bedeutet, dass Sie gezwungen sind anzunehmen, dass alles gebraucht werden könnte, weil Sie den Punkt der Graphdurchquerung nicht kontrollieren. Und das bedeutet, dass Sie die Fähigkeit verlieren, SQL aus dem Service-Code vorherzusagen. Für die Persistenzschicht ist das fast ein Todesurteil: Wenn Sie SQL nicht vorhersagen, kontrollieren Sie das System nicht.

4. Unbeabsichtigte Serialisierung: toString(), Logging, JSON

Unbeabsichtigte Serialisierung liegt vor, wenn der Entity-Graph nicht durchlaufen wird, weil „die Geschäftslogik Daten braucht“, sondern weil jemand „einfach schön ausgeben“ wollte. Die häufigsten Auslöser: toString(), Logging und JSON-Serialisierung. Das Unangenehmste daran ist, dass alle drei wie rein technische Details aussehen, die nichts mit der Datenbank zu tun haben. Genau deshalb sind sie besonders gefährlich.

Beginnen wir mit toString(). Wenn toString() bei einer Entity alles ausgibt (oder, schlimmer noch, Collections ausgibt), kann ein gewöhnliches Log plötzlich Lazy Loading auslösen. Und Sie schauen auf den SQL-Trace und denken: „Wer hat diese Abfrage ausgelöst?“, obwohl die Antwort „der Logger“ ist.

Deshalb halten wir im Schulungsprojekt toString() bewusst maximal langweilig. Ja, ein langweiliges toString() ist eine Tugend. Schließlich ist es unsere Aufgabe, SQL vorhersagbar zu machen, nicht dass die Log-Zeile wie ein Roman von Tolstoi aussieht.

@Override
public String toString() {
    // Prinzip: Nicht in Assoziationen und Collections steigen (customer/items), sonst wird Logging zur SQL-Quelle
    return "PurchaseOrder{id=%d, orderNumber='%s'}".formatted(id, orderNumber);
}

Der zweite Auslöser ist Logging über Templates, bei denen der Logger selbst toString() aufruft.

// Wichtig zu merken: {} zwingt den Logger, toString() für das Objekt aufzurufen
log.info("Closing order: {}", order); // Ruft order.toString() auf

Wenn toString() „gierig“ ist, wird Logging zu einer Datenbankleseoperation. Und zwar an der unangenehmsten Stelle: in der Fehlerbehandlung, in finally, in der Diagnostik. Also genau dort, wo Sie am wenigsten eine weitere Abfrage gebrauchen können.

Der dritte Auslöser ist JSON-Serialisierung. Hier ist ein Gedanke wichtig: Sobald Sie eine Entity nach außen geben (z. B. in die Web-Schicht), beginnt der Serialisierer, Getter und Assoziationen zu durchlaufen. Er weiß nicht, was „Ihr Use Case“ ist, er weiß nur: „Ich muss das Objekt in JSON verwandeln“. Und wenn das Objekt ein Entity-Graph ist, wird der Serialisierer brav den Graphen entlanggehen. Manchmal bis zur Unendlichkeit (Zyklen), manchmal bis zur LazyInitializationException, manchmal bis zum N+1-Problem.

Es ist wichtig, den Hauptpunkt zu erfassen: Unbeabsichtigte Serialisierung entsteht als Folge von Entity Leakage. Solange die Entity dort lebt, wo sie hingehört, können Sie das Laden kontrollieren. Aber wenn die Entity zu einem Transportcontainer für Daten wird, wird jedes Werkzeug „zur schönen Ausgabe“ plötzlich Teil Ihres ORM-Verhaltens.

5. Kontrolle zurückgewinnen: Explizites Lesemodell

Daher der erste gesunde Reflex: Wenn ein Use Case Daten liest, sollte nach außen keine „lebendige“ Entity gehen, sondern eine Leseform, die dieser Use Case tatsächlich verspricht. Eine Entity ist dort gut, wo Sie ein Objekt innerhalb einer Transaktion ändern. Für eine Zusammenfassung, eine Liste, einen Bericht oder eine externe Ausgabe ist sie normalerweise zu schwer und zu unvorhersehbar.

Manchmal reicht sogar ein sehr schmales Lesemodell:

// Fixe Leseform: Hier kann nichts nachgeladen werden und nichts unerwartet zur DB gehen
public record OrderSummary(Long id, String orderNumber, String customerEmail) { }

Danach kann der externe Code beliebig die Felder von summary lesen, ohne dass neuer SQL entsteht. Wichtig ist das Prinzip: Die Datenform muss dort festgelegt werden, wo Sie das Lesen entwerfen, nicht später durch zufällige Getter-Aufrufe an der Entity berechnet werden.

Und auch Projection ist keine Zauberei. Sobald Sie verschachtelte Eigenschaften und zugehörige Daten nachziehen, wählen Sie bereits den Join, die Breite des SELECT und die tatsächlichen Lesekosten. Die Kontrolle verschwindet also nicht – sie kehrt dorthin zurück, wo sie hingehört.

6. Typische Fehler im Umgang mit Entity

Fehler Nr. 1: „Geben wir einfach die Entity zurück – später kümmern wir uns darum.“
Dieser Ansatz beginnt fast immer als Zeitersparnis und endet damit, dass der Ladepunkt der Daten durch das Projekt wandert. Heute wird der Getter innerhalb der Transaktion aufgerufen und „alles ist ok“, morgen ruft ihn jemand nach der Transaktion auf und fängt eine LazyInitializationException, übermorgen schaltet eine andere Person einen infrastrukturellen Krückstock ein und erhält Dutzende Abfragen an unerwarteter Stelle.

Fehler Nr. 2: Entity Leakage mit Serialisierungsannotationen und „Flicken“ behandeln.
Man möchte „schnell reparieren“, indem man irgendwo @JsonIgnore, woanders @JsonManagedReference hinzufügt und dann noch ein paar Ausnahmen – und schon wird JSON wieder ausgegeben. Das Problem ist, dass Sie Symptome behandeln, nicht die Ursache. Wenn der Use Case eine Zusammenfassung liefern soll, liefern Sie eine Zusammenfassung. Wenn er Details liefern soll, liefern Sie Details. Versuchen Sie nicht, den Entity-Graphen zu einem universellen Schweizer Taschenmesser für alle Ausgabeformen zu machen.

Fehler Nr. 3: toString() „schön“ machen, indem der Graph durchlaufen wird.
Eine Entity hat nicht die Pflicht, ein „sich selbst beschreibendes Objekt zu sein, das alles ausgibt“. Ihre Pflicht ist es, im Rahmen des ORM vorhersagbar zu sein. Wenn toString() Lazy-Collections oder verwandte Entitäten anfasst, verwandeln Sie Logging in ein SQL-Szenario. Das ist selten den Aufwand wert, selbst wenn man sich ein bequemes Debugging wünscht.

Fehler Nr. 4: „Leseobjekt“ und „Objekt zum Ändern“ verwechseln.
Eine Entity ist ein hervorragender Kandidat für Schreibszenarien: Sie laden ein Aggregat in einer Transaktion, ändern Felder, Hibernate macht Dirty Checking und sendet ein UPDATE. Für das Lesen einer Liste oder einer Zusammenfassung ist eine Entity jedoch normalerweise zu schwer und zu gefährlich. Wenn Sie diese Rollen vermischen, entstehen Giant Graphs und Unvorhersehbarkeit, gefolgt von Versuchen, „alles EAGER zu machen“ oder „alles im Voraus zu laden“.

Fehler Nr. 5: Ein „Giant DTO“ erstellen, das den Entity-Graphen eins zu eins kopiert.
Manchmal hat der Entwickler die Idee „Entity nicht zurückgeben“ verstanden, macht aber stattdessen ein DTO in Enzyklopädiegröße: OrderDto enthält den Kunden, die Adressen des Kunden, die Positionen, die Produkte, die Produktdetails, die Kategorien … und das in jedem Szenario. Formal ist die Entity nicht mehr durchgesickert, aber das Problem der Datenform bleibt: Der Use Case ist immer noch nicht definiert. Ein DTO sollte keine „Kopie der Domäne“ sein, sondern die Widerspiegelung einer bestimmten Leseoperation.

1
Aufgabe
Hibernate deep-dive, Level 29, Lektion 0
Gesperrt
Rechnungsübersicht ohne Entity-Leakage
Rechnungsübersicht ohne Entity-Leakage
1
Aufgabe
Hibernate deep-dive, Level 29, Lektion 0
Gesperrt
Sicherer `toString()` für lazy-Kollektion
Sicherer `toString()` für lazy-Kollektion
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION