1. Manchmal braucht man EntityGraph statt JOIN FETCH
Wenn JOIN FETCH bedeutet, „eine Turbine direkt in die Abfrage einzubauen“, dann heißt EntityGraph so viel wie: „Hibernate mitteilen, welche Details wir zusammen mit dem Auto bekommen wollen, ohne den Kaufvertrag neu zu schreiben“. In einem realen Projekt bleibt die Abfrage (Filterung, Sortierung) häufig dieselbe, aber die Menge der benötigten Assoziationen variiert je nach Szenario: für eine Liste – eines, für eine Detailkarte – etwas anderes, für eine interne Geschäftsoperation – ein Drittes.
Stellen wir uns einen verständlichen Use-Case aus unserem Commerce Persistence Lab vor. Wir lesen eine PurchaseOrder anhand der orderNumber. Manchmal benötigen wir nur die Bestellung selbst und den Kunden (um den Kopf anzuzeigen). Manchmal brauchen wir die Bestellung plus die Positionen (um den Inhalt darzustellen). Manchmal benötigen wir die Bestellung plus Positionen plus das product innerhalb jeder Position (um SKU und Name anzuzeigen). Wenn wir jedes Mal das JPQL für eine neue Variante umschreiben, erhalten wir schnell einen Zoo von Methoden wie findOrderDetailedWithCustomerAndItemsAndProductsAndWhateverElse(), und nach einer Woche werden Sie selbst nicht mehr verstehen, worin sie sich unterscheiden.
EntityGraph löst genau dieses Problem: Es ermöglicht, die Abfrage (Bedingungen) unverändert zu lassen, aber einen Fetch-Plan daran zu „hängen“ – eine Liste von Attributen/Assoziationen, die im Rahmen des aktuellen Lesevorgangs vorab geladen werden sollen.
2. Was ist ein EntityGraph
Die Idee hinter EntityGraph ist sehr einfach, wenn man es verständlich erklärt: Es ist eine „Karte der Assoziationen, die beim Laden einer Entität initialisiert werden sollen“. Es ist also keine neue Abfragesprache, kein Ersatz für where/order by und keine Möglichkeit auszuwählen, „welche Zeilen“ – es wählt aus, „welche Teile des Objekts“ sofort materialisiert werden sollen. Es lebt in der Welt der Fetch-Strategien, nicht in der Welt der Filterung.
Es ist nützlich, das folgende vereinfachte Schema im Kopf zu behalten:
flowchart LR
A["JPQL/Repository-Methode
was wird gelesen (Bedingungen)"] --> B["EntityGraph
was wird sofort nachgeladen (Fetch-Plan)"]
B --> C["Hibernate-Fetch-Plan"]
C --> D["SQL (Join / sekundäre Selects)
und das Ergebnis im Persistenzkontext"]
Das heißt, wir haben zwei „Steuerhebel“ statt einem:
- Die Abfrage beantwortet die Frage: „Welche Bestellungen/Produkte/Kunden werden benötigt?“;
- Der Graph beantwortet die Frage: „Was genau soll jetzt mit ihnen zusammen geladen werden?“
Das passt sehr gut zur Philosophie des Tages: LAZY bleibt das grundlegende Modell (wir verwandeln die Entität nicht in „für immer alles EAGER“), und Eagerness machen wir szenarisch, für ein bestimmtes Lesen.
Eine kleine Minitabelle zur Festigung der Unterschiede (im Rahmen unseres heutigen Tages):
| Werkzeug | Wo festgelegt | Was verändert es | Kernaussage |
|---|---|---|---|
| JOIN FETCH | im JPQL/HQL | die Form der Abfrage (oft ein Join im SQL) und die Initialisierung der Assoziation | „Wir haben die Abfrage explizit für das Detail-Lesen umgeschrieben“ |
| EntityGraph | separat (Annotation oder im Code) | den Fetch-Plan: welche Assoziationen geladen werden sollen | „Die Abfrage bleibt gleich, nur der Ladeplan ändert sich“ |
Hier ist es wichtig, den Graphen nicht zu überschätzen. EntityGraph ändert die Art und Weise, wie der Fetch-Plan beschrieben wird, nicht jedoch die Physik des Lesens. Wenn der Graph to-many enthält, zahlt Hibernate trotzdem den Preis für das Collection-Loading: Das können Joins, sekundäre SELECTs, Empfindlichkeit gegenüber Paginierung und all die gleichen Fragen zu mehreren Collections sein. Der Graph hebt diese Mathematik nicht auf, er erlaubt lediglich, den Fetch-Plan getrennt von der Filterung zu steuern.
3. Möglichkeiten zur Deklaration eines EntityGraph
@NamedEntityGraph in der Entität
Der „klassischste“ Weg, EntityGraph zu verwenden, ist die Beschreibung eines benannten Graphen direkt an der Entität. Das ist vergleichbar damit, wie Sie Ihrem Lieblingsrezept einen Namen geben: „Borschtsch_wie_bei_Oma“. Dieses Rezept können Sie dann an verschiedenen Stellen „beim Namen rufen“. Der Hauptvorteil: Der Graph ist zentralisiert und wiederverwendbar, der Hauptnachteil: Sie fügen der Entität ein weiteres „Hilfselement“ hinzu.
Lassen Sie uns im Projekt einen einfachen Graphen für das Lesen einer detaillierten Bestellung erstellen: Bestellung + Kunde + Positionen. Wichtig: Wir nehmen hier nichts Überflüssiges mit, denn der Graph sollte ein bestimmtes Szenario abbilden (z. B. den Bildschirm „Bestelldetails“ im Backoffice).
Beispiel (Ausschnitt aus PurchaseOrder):
import jakarta.persistence.Entity;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.NamedEntityGraph;
// Benannter Graph: legt einen szenarischen Fetch-Plan fest, um ihn namentlich wiederzuverwenden
@NamedEntityGraph(
name = "PurchaseOrder.withCustomerAndItems",
attributeNodes = {
// Lädt den Kunden zusammen mit der Bestellung
@NamedAttributeNode("customer"),
// Lädt die Bestellpositionen (Collection)
@NamedAttributeNode("items")
}
)
@Entity // Die Entität bleibt LAZY-orientiert, der Graph gibt Eager nur für das Lesen vor
public class PurchaseOrder {
// ...
}
Beachten Sie zwei Dinge. Erstens benennen wir den Graphen so, dass die Bestimmung aus dem Namen hervorgeht: withCustomerAndItems. Zweitens listen wir Attribute der Entität auf, nicht Tabellen oder Spalten. customer und items sind Felder in PurchaseOrder, und genau diese soll Hibernate sofort zu laden versuchen.
Wenden wir diesen Graphen nun beim Lesen über EntityManager.find(...) an. Das ist wichtig: EntityGraph funktioniert sogar dort, wo Sie überhaupt kein JPQL schreiben.
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import java.util.Map;
// Den Graphen anhand seines Namens holen (wie ein „Rezept“ für den Fetch-Plan)
EntityGraph<?> graph = entityManager.getEntityGraph("PurchaseOrder.withCustomerAndItems");
PurchaseOrder order = entityManager.find(
PurchaseOrder.class,
orderId,
// Dem Provider mitteilen: fetchgraph bei diesem spezifischen Lesen anwenden
Map.of("jakarta.persistence.fetchgraph", graph)
);
Der Code sieht fast „wie immer“ aus, nur dass wir eine properties map mit dem Schlüssel "jakarta.persistence.fetchgraph" hinzugefügt haben. Dies ist ein standardmäßiger JPA-Mechanismus: Wir teilen dem Provider (Hibernate) mit, dass für dieses Lesen ein Fetch-Plan angewendet werden soll.
Und ja, der Schlüssel beginnt mit jakarta.persistence, weil wir uns in der Welt von Spring Boot 4 + Hibernate 7 befinden, wo bereits Jakarta Persistence gilt, nicht javax.persistence.
Dynamischer Graph im Code
Manchmal möchten Sie keine Annotationen zur Entität hinzufügen. Die Gründe können unterschiedlich sein: Sie betrachten das Domänenmodell als „heilige Kuh“ (und es wird böse bei Annotationen), Sie haben Legacy-Einschränkungen, oder Sie möchten einfach schnell einen Graphen für ein Labor zusammenbauen und später wieder verwerfen. Für solche Fälle erlaubt JPA, einen EntityGraph programmatisch zu erstellen.
Beispiel: Wir bauen einen Graphen für PurchaseOrder direkt im Code eines Services/Labors und wenden ihn über einen Hint auf eine JPQL-Abfrage an.
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
// Dynamischen Graphen für eine bestimmte Entität erstellen
EntityGraph<PurchaseOrder> graph = entityManager.createEntityGraph(PurchaseOrder.class);
// Explizit auflisten, welche Attribute nachgeladen werden sollen
graph.addAttributeNodes("customer", "items");
TypedQuery<PurchaseOrder> q = entityManager.createQuery(
// JPQL ist nur für „welche Zeile“ (welche Bestellung) zuständig, ohne fetch join
"select o from PurchaseOrder o where o.id = :id",
PurchaseOrder.class
);
q.setParameter("id", orderId);
// Fetch-Plan über einen Hint anhängen (Modus loadgraph oder fetchgraph – je nach Aufgabenstellung)
q.setHint("jakarta.persistence.loadgraph", graph);
PurchaseOrder order = q.getSingleResult();
Hier haben wir zwei wichtige Dinge getan. Wir haben das JPQL so einfach wie möglich gehalten (ohne fetch join), es beantwortet also nur die Frage „welche Bestellung nehmen“. Und dann haben wir über setHint() einen Graphen daran gehängt, also die Frage beantwortet „was soll nachgeladen werden“.
In einem realen Projekt ist dieser Stil sehr praktisch, wenn die Filterung komplex ist und mehrfach wiederverwendet wird, der Fetch-Plan jedoch variabel ist. Sie möchten nicht denselben where-Teil in drei Abfragevarianten kopieren, nur weil an einer Stelle items benötigt werden und an einer anderen nicht.
4. Die Modi fetchgraph und loadgraph
Es gibt eine Feinheit bei EntityGraph, die es jetzt zu verstehen gilt, sonst werden Sie genau in dem Moment leiden, in dem keine Zeit zum Leiden ist (also in der Produktion). JPA hat zwei nahe, aber unterschiedliche Modi für die Anwendung eines Graphen: fetchgraph und loadgraph. Sie unterscheiden sich darin, was mit Attributen passiert, die nicht im Graphen aufgeführt sind.
Lassen Sie es uns in menschlicher Sprache sagen. Beide Modi besagen: „Was im Graphen aufgeführt ist, lade jetzt nach“. Der Unterschied beginnt erst danach.
fetchgraph klingt wie: „Lade nur das, was ich angegeben habe“. Und loadgraph klingt wie: „Lade das, was ich angegeben habe, und den Rest so, wie es im Mapping konfiguriert ist“. Das ist kein wörtliches Zitat aus der Spezifikation, aber eine brauchbare ingenieursmäßige Intuition.
Fassen wir in einer kompakten Tabelle zusammen:
| Modus | Was mit Attributen aus dem Graphen passiert | Was mit Attributen außerhalb des Graphen passiert |
|---|---|---|
| fetchgraph (jakarta.persistence.fetchgraph) | Sie gelten als „müssen geladen werden“ | Sie gelten als „jetzt nicht laden“ (auch wenn irgendwo EAGER) |
| loadgraph (jakarta.persistence.loadgraph) | Sie gelten als „müssen geladen werden“ | Das Verhalten bleibt „wie im Mapping“ (LAZY/EAGER wie festgelegt) |
Nun ein wichtiger praktischer Gedanke. In unserem Kurs wiederholen wir ständig: Verwenden Sie nicht standardmäßig EAGER. Das bedeutet, dass wenn Ihr Modell diszipliniert LAZY-orientiert ist, der Unterschied zwischen fetchgraph und loadgraph seltener zum Tragen kommt. Aber in der Realität werden Sie vielleicht auf EAGER stoßen (oder „versehentlich eager“ durch irgendwelche Einstellungen/Vererbung/alten Code). Und dann wird fetchgraph zu einer Möglichkeit, überflüssige Eager-Nachladungen zu „beschneiden“ und das Lesen vorhersagbarer zu machen.
Wenn Sie mit Spring Data arbeiten, werden Sie diesen Konzepten ebenfalls begegnen, nur in einer anderen Form: Dort hat @EntityGraph einen Parameter type, der FETCH oder LOAD akzeptiert.
5. EntityGraph in Spring Data JPA
Bisher haben wir auf der Ebene von JPA gesprochen (EntityManager). Das ist hilfreich zum Verständnis, aber in einer echten Spring-Boot-Anwendung arbeiten Sie fast immer über Repositories. Die gute Nachricht: Spring Data JPA kann EntityGraph anwenden, ohne dass Sie sich direkt mit EntityManager herumschlagen müssen, und das macht den Code oft deutlich sauberer.
Machen wir in unserem Projekt einen kleinen Schritt: Fügen wir in PurchaseOrderRepository eine Methode hinzu, die eine Bestellung zusammen mit Kunde und Positionen ausliest.
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Szenarischer Fetch-Plan: Für diese Methode werden die benötigten Assoziationen vorab geladen
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrder> findOneByOrderNumber(String orderNumber);
}
Hier geschieht ein wenig Magie (aber jetzt ist es bereits „verständliche Magie“). Spring Data nimmt Ihre Methode (die an sich wie eine normale abgeleitete Abfrage aussieht), erstellt eine Abfrage und fügt dann einen JPA-Hint mit einem aus attributePaths zusammengestellten Graphen hinzu.
Wenn Sie einen benannten Graphen in der Entität haben, können Sie auch darauf verweisen:
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Verwendet den benannten Graphen aus der Entität („Rezept“ wiederverwenden)
@EntityGraph(value = "PurchaseOrder.withCustomerAndItems")
Optional<PurchaseOrder> findById(Long id);
}
Beachten Sie den Trick: Wir haben findById() in unserem Interface überschrieben und die Annotation hinzugefügt. Die Signatur ist dieselbe, aber das Ladeverhalten ist nun „detailliert“. Das ist praktisch, sollte aber mit Bedacht angewendet werden: Wenn Sie überall die „detaillierte“ findById verwenden, können Sie leicht wieder in Overfetching verfallen (diesmal über den Graphen, nicht über EAGER).
Um nicht alle Use Cases in einen Topf zu werfen, erstellt man häufiger zwei Methoden: eine „normale“ und eine „detaillierte“.
Zum Beispiel die normale findById so lassen wie sie ist und für die Details eine separate Methode mit @Query einführen:
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// JPQL bleibt einfach, der Fetch-Plan wird ins EntityGraph ausgelagert
@EntityGraph(attributePaths = {"customer", "items"})
@Query("select o from PurchaseOrder o where o.id = :id")
Optional<PurchaseOrder> findDetailedById(Long id);
}
Beachten Sie: Die Abfrage blieb einfach, ohne join fetch. Wir haben den Fetch-Plan in @EntityGraph ausgelagert. Das ist genau der Stil, für den wir uns heute entschieden haben.
Und noch ein schöner Bonus von Spring Data: attributePaths unterstützt verschachtelte Pfade. Wenn Sie items.product laden müssen (z. B., um die SKU in den Bestellpositionen anzuzeigen), können Sie den Pfad als String angeben:
// Verschachtelter Pfad: Lädt das product innerhalb jeder Bestellposition
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<PurchaseOrder> findDetailedById(Long id);
Ja, das sieht nach einem „String-Magie“ aus. Aber es ist ein kontrollierter String: Er spiegelt die Navigation durch die Felder des Entitätsgraphen wider.
6. Durchgängiges Beispiel: Bestelldetails mit EntityGraph
Damit das alles nicht wie „Annotationen um der Annotationen willen“ aussieht, verknüpfen wir es mit einem Service-Use-Case. Nehmen wir an, wir haben einen OrderQueryService, der ein detailliertes Bestellmodell für den Backoffice-Bildschirm zurückgibt (vorerst ohne DTO und ohne Projektionen – das behandeln wir heute bewusst nicht).
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final PurchaseOrderRepository orderRepository;
public OrderQueryService(PurchaseOrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
// Das Lesen bleibt in einer Transaktion, damit LAZY-Teile nicht zu LazyInitializationException führen
@Transactional(readOnly = true)
public PurchaseOrder loadOrderDetails(Long orderId) {
return orderRepository.findDetailedById(orderId)
// Im Beispiel ohne benutzerdefinierte Nachricht: wichtig, dass der Fehler sofort sichtbar wird
.orElseThrow();
}
}
Hier ist die Verbindung zu dem wichtig, was wir früher behandelt haben. Wir halten das Lesen weiterhin innerhalb einer Transaktion (@Transactional(readOnly = true)), weil sonst jede LAZY-Assoziation zu einer LazyInitializationException führen kann. EntityGraph hebt diese Realität nicht auf, es hilft lediglich dabei, die benötigten Assoziationen vorab zu laden, sodass Sie später innerhalb der Unit of Work entspannt mit dem Objekt arbeiten können.
7. EntityGraph anhand des SQL-Trace überprüfen
Es gibt eine Anfängerfalle bei EntityGraph: Sie haben eine Annotation hinzugefügt, der Code ist „nicht abgestürzt“, und es scheint, als ob alles in Ordnung wäre. Aber Hibernate ist ein durchtriebenes System. Es kann einen Join wählen, es kann zusätzliche SELECTs wählen, es kann teilweise optimieren, es kann ein distinct erzeugen, es kann die Reihenfolge der Abfragen ändern. Wenn Sie den SQL-Trace nicht ansehen, ist es, als würden Sie die Elektrik in Ihrer Wohnung nach Gehör reparieren: Manchmal klappt es, aber die Methode ist fragwürdig.
Deshalb ist die Überprüfung immer dieselbe, wie ein Mantra des Kurses. Sie starten das Szenario unter dem Profil sql-trace (und optional stats), führen das Lesen aus und schauen nach, was passiert ist: wie viele Abfragen, von welcher Art, wo sekundäre SELECTs verschwunden sind und wo im Gegenteil Joins aufgetaucht sind.
Um den Unterschied zu spüren, ist es nützlich, zwei Lesevorgänge „bei derselben Abfrage“ zu vergleichen:
1) Ohne Graph: Wir lesen die Bestellung, greifen dann auf getItems() und getCustomer() zu – wir beobachten sekundäre SELECTs.
2) Mit Graph: Wir lesen die Bestellung mit derselben Abfrage, aber über @EntityGraph – wir prüfen, ob die sekundären SELECTs verschwunden sind und wie das SQL aussieht.
Auf Codeebene kann das so unspektakulär aussehen, dass es sogar schön ist:
// Wichtig: Dieser Code sollte innerhalb einer Transaktion überprüft werden (oder im Rahmen eines Tests mit offener Session)
PurchaseOrder order = orderQueryService.loadOrderDetails(orderId);
// Kontrollpunkt: Greifen wir auf die Assoziationen zu und sehen im Log, ob sekundäre SELECTs auftauchen
order.getCustomer().getEmail(); // im Idealfall ohne separaten SELECT
order.getItems().size(); // im Idealfall ohne separaten SELECT
Und dann öffnen Sie das Log und sehen die Realität. Manchmal ist es ein SQL mit Joins. Manchmal sind es zwei SQLs: einer für die Bestellung und den Kunden, einer für die Collection. Und das ist normal: EntityGraph ist kein Versprechen für „eine Abfrage für alles“, es ist ein Versprechen für „ich werde versuchen, dies im Rahmen eines Lesevorgangs vorab zu laden“.
Und daraus ergibt sich eine weitere Entscheidungsrichtung: Manchmal ist es besser, nicht alles auf einmal zu laden, sondern LAZY zu belassen und einfach die Anzahl der sekundären Abfragen zu reduzieren. Das ist eine andere Art von Lösung – kein Eager-Graph, sondern ein gruppiertes Lazy Loading.
8. Typische Fehler beim Arbeiten mit EntityGraph
Fehler Nr. 1: Denken, dass EntityGraph eine Möglichkeit zur Datenfilterung ist.
Manchmal versuchen Studenten, den Graphen zu benutzen, als wäre er ein where: „Wenn ich items zum Graphen hinzufüge, werden nur Bestellungen mit Positionen geladen.“ Nein. EntityGraph wählt keine Zeilen aus. Es wählt aus, welche Assoziationen geladen werden. Die Filterung bleibt Aufgabe der Abfrage (where) und Ihrer Logik.
Fehler Nr. 2: fetchgraph und loadgraph verwechseln und sich dann über „überflüssige“ Ladungen wundern.
Von außen sieht der Unterschied mikroskopisch aus: ein Wort im Hint oder der Parameter type in @EntityGraph wurde geändert. Aber die Folgen können durchaus spürbar sein, besonders wenn irgendwo im Mapping ein EAGER lauert. Wenn Sie nicht verstehen, was mit den nicht aufgeführten Attributen passiert, verlieren Sie die Vorhersagbarkeit beim Lesen – und Vorhersagbarkeit ist im ORM wertvoller als Gold.
Fehler Nr. 3: Einen riesigen Graphen für „alles“ erstellen und sich freuen, dass es weniger Abfragen gibt.
Ja, es könnten weniger Abfragen sein, aber die Größe des Resultsets und die Datenbreite können zunehmen. Später öffnen Sie das SQL und sehen, dass Sie das Lesen versehentlich in ein Monster mit vielen Joins verwandelt haben, das der Datenbank weh tut. Der Graph sollte szenarisch sein: für einen bestimmten Bildschirm/Vorgang, nicht als „universeller Graph des Weltkatalogs“.
Fehler Nr. 4: EntityGraph als „Heilmittel“ gegen LazyInitializationException verwenden, ohne die Transaktionsgrenzen zu korrigieren.
Der Graph hilft, Assoziationen beim Lesen nachzuladen, aber wenn Sie die Entität außerhalb der Transaktion weiterverwenden und dort auf LAZY-Assoziationen zugreifen, können Sie trotzdem auf die Ausnahme stoßen. Die architektonische Disziplin bleibt dieselbe: Entweder laden wir alles Notwendige innerhalb der Transaktion, oder wir gestalten das Lesen so, dass Sie außerhalb der Transaktion keine lazy Teile anfassen.
Fehler Nr. 5: Das Ergebnis nicht anhand des SQL-Trace überprüfen und annehmen, dass die Annotation ein bestimmtes SQL garantiert.
EntityGraph beeinflusst den Fetch-Plan, und Hibernate entscheidet selbst, wie es ihn umsetzt. Wenn Sie das SQL nicht ansehen, sehen Sie den Preis der Lösung nicht. In einem Deep-Dive-Kurs ist das fast ein Verbrechen (aber keine Sorge, wir werden Sie einfach zwingen, das sql-trace zu aktivieren, und alles wird gut).
GO TO FULL VERSION