1. Unterschied zwischen Entitäts-Mapping und Projektion
Wenn Sie jemals Code im Stil „Laden wir doch List<Product> und machen dann in Java .map(p -> new ProductListRow(...))“ geschrieben haben, sind Sie nicht allein. Das ist der natürlichste Reflex, denn in Java macht es Spaß, Objekte von Hand zu bauen: Die IDE hilft, der Compiler meckert vorher, und stream() wirkt zivilisiert. Das Problem ist, dass ein solcher Ansatz oft nicht den Hauptschmerz löst, den wir in der ersten Vorlesung besprochen haben: Sie haben trotzdem zuerst eine managed-Entität mit all ihren Eigenheiten geladen und dann nur ein „kosmetisches Make-up“ in Form eines DTO gemacht.
Schauen wir uns die naive Variante an (sie ist nicht „schlecht“, sie spart einfach keine ORM-Kosten):
import java.util.List;
// Laden der managed-Entität (mit Persistenzkontext, Proxy, Dirty Checking usw.)
List<Product> products = productRepository.findByStatus(ProductStatus.ACTIVE);
// DTO erst nach dem Laden der Entitäten (d.h. „verputzen“ über die ORM-Kosten)
return products.stream()
.map(p -> new ProductListRow(p.getId(), p.getSku(), p.getName()))
.toList();
Das sieht oberflächlich schön aus. Aber vor diesem Mapping hat Hibernate möglicherweise bereits ein breites SELECT ausgeführt, eine Entität materialisiert, sie in den Persistenzkontext aufgenommen, Proxy/Kollektionen für Assoziationen erstellt (abhängig vom Mapping), und wenn Sie versehentlich etwas berührt haben, sind auch Dirty Checking und Flush nicht abgeschaltet. Das DTO erweist sich hier als „zweite Putzschicht“ auf einer bereits bezahlten Rechnung.
Projektion ist ein anderer Ansatz: Wir sagen Hibernate sofort: „Ich brauche keine Entität, ich brauche ein Ergebnis in einer bestimmten Form.“ Dann baut Hibernate ein SQL mit den benötigten Spalten und liefert normale Java-Objekte zurück, die nicht managed werden und nicht im Persistenzkontext leben.
Um den Unterschied zu verfestigen, kann man sich zwei Lieferwege vorstellen:
flowchart TD
A["JPQL/Repository call"] --> B["Hibernate erstellt SQL"]
B --> C["JDBC ResultSet"]
C --> D1["Entity-Materialisierung + Persistenzkontext"]
D1 --> E1["Java-Mapping zu DTO"]
C --> D2["DTO-Erstellung direkt aus dem Ergebnis (Projektion)"]
style D1 fill:#ffe6e6,stroke:#cc0000
style D2 fill:#e6ffe6,stroke:#009900
Der obere Pfad (über die Entität) ist manchmal gerechtfertigt, aber für Listen oft überdimensioniert. Der untere Pfad ist das, worauf wir jetzt zusteuern.
2. DTO für Listen: Was ist das?
Wenn man „DTO“ sagt, haben viele eine monströse Klasse mit 35 Feldern vor Augen, die „für alle Fälle“ alles enthält, was jemals nützlich sein könnte. Ein solches DTO stirbt normalerweise nicht an Compilerfehlern, sondern daran, dass man sich fürchtet, es anzufassen. Wir brauchen einen anderen Stil: ein kleines DTO für genau eine bestimmte Liste. Es ist im Grunde eine „Tabellenzeile“ im Backoffice, nicht eine „Miniatur der gesamten Product-Entität“.
In unserem Commerce Persistence Lab möchte ein typischer Lese-Fall für Produkte vielleicht nur drei Spalten: id, sku, name. Also sollte das DTO genauso einfach sein. In Java 25+ eignet sich dafür hervorragend record: Es ist kompakt, unveränderlich und ideal für ein Read-Modell. Wenn Ihnen record noch wie „Magie“ vorkommt, betrachten Sie es als Kurzschreibweise einer normalen Klasse mit Feldern, Konstruktor und Gettern.
Beispiel für eine DTO-Zeile einer Produktliste:
package com.example.commerce.catalog.dto;
// DTO für die Liste: genau das, was die Tabelle/Liste braucht, ohne „Reservefelder“
public record ProductListRow(Long id, String sku, String name) {
// Wichtig: Das ist ein Read-Modell, hier sollte keine Geschäftslogik und keine Seiteneffekte geben
}
Beachten Sie Details, die in einem echten Projekt sehr helfen:
Erstens: Der Name ProductListRow sagt klar: Das ist eine Listenzeile, kein „Allzweck-Produkt für alles“. Zweitens: Die Typen sind Referenztypen (Long, String), sodass Sie keine Überraschung in Form eines NPE erleben, wenn Sie versuchen, null in einen primitiven Typ zu stecken. Drittens: Es gibt keine „für alle Fälle“-Felder wie description, details, categories – das sind andere Anwendungsfälle, die wir mit einem anderen Lesevorgang lösen werden.
Für eine Bestellliste kann die Ergebnisform anders sein. Zum Beispiel zeigt die Bestelltabelle im Adminbereich oft orderNumber, status und die E-Mail des Kunden. Das ist ein anderer begrenzter Kontext, und das DTO lebt im Paket orders.dto:
package com.example.commerce.orders.dto;
import com.example.commerce.orders.entity.OrderStatus;
// DTO für die Bestellliste: flache Ergebnisform für einen bestimmten Bildschirm/eine bestimmte Tabelle
public record OrderListRow(Long id, String orderNumber, String customerEmail, OrderStatus status) {
// customerEmail ist ein „Ausschnitt“ der verknüpften Entität Customer, kein eingebettetes Objekt
}
Hier gilt ein wichtiges Prinzip: Read-Modelle sollten flach sein, auch wenn es in der Datenbank Verknüpfungen gibt. Wir brauchen nicht das gesamte Customer-Objekt, sondern nur die E-Mail-Zeichenkette. Das spart sowohl die Breite des Ergebnisses als auch die kognitive Komplexität.
Eine kleine Tabelle zum schnellen Vergleich von Entität und DTO-Projektion als Leseergebnisse:
| Merkmal | Entität () |
DTO-Projektion () |
|---|---|---|
| Lebt im Persistenzkontext | Ja (managed) | Nein |
| Dirty Checking | Ja | Nein |
| Lazy Loading | Kann „plötzlich“ ausgelöst werden | Nichts ist faul: Es sind nur Daten |
| Risiko einer versehentlichen UPDATE-Aktion | Reell | Nahezu null (wenn Sie keinen separaten Update-Abfrage durchführen) |
| Geeignet zum Ändern von Daten | Ja, es ist ein Write-Modell | Nein, es ist ein Read-Modell |
Und hier kommt der Hauptpunkt: Wir werden nicht „ärmer“ ohne die Entität; wir machen den Lese-Vertrag einfach ehrlich.
3. JPQL-Konstruktorausdruck: select new
JPQL (und HQL) ist eine Abfragesprache, die aussieht wie „SQL, aber für Objekte“. Hibernate nimmt JPQL, wandelt es in SQL für Ihr Dialekt (hier PostgreSQL) um und führt es aus. Für class-basierte Projektionen gibt es in JPQL eine spezielle Syntax: Konstruktorausdruck. Sie sieht so aus: select new <FQCN>(...).
Warum gerade so? Weil Hibernate verstehen muss, welches Objekt aus jeder Ergebniszeile erstellt werden soll. Wenn wir eine Entität zurückgeben, weiß Hibernate, was zu tun ist: Entität laden, in den Persistenzkontext legen, den Zustand überwachen. Wenn wir jedoch ein DTO zurückgeben, braucht Hibernate eine „Bauanleitung“, wie aus den ausgewählten Spalten ein Objekt zusammengesetzt wird. Diese Bauanleitung ist new.
Einfachstes Beispiel (für ein Produkt):
// JPQL: DTO direkt aus dem Abfrageergebnis (ohne Materialisierung der Entität)
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
where p.status = :status
Hier gibt es einige wichtige „Fallstricke“, und es ist besser, sie im Übungsprojekt zu erleben als am Freitagabend in der Produktion.
Der erste Punkt: In JPQL geben Sie fast immer den vollständigen Klassennamen an (com.example...ProductListRow). Hibernate „errät“ nichts aus Imports, weil es ein String innerhalb einer Annotation ist. Die IDE kann Ihnen die Klasse als String hervorheben – und dann ist sie machtlos. Das ist der Preis für String-basierte Abfragen.
Der zweite Punkt: Die Reihenfolge der Argumente ist wichtig. Hibernate sucht nicht nach einem „passenden Konstruktor nach Namen“, sondern nach einem kompatiblen Konstruktor nach Positionen und Typen. Wenn Sie das DTO vertauschen (sku und name), aber die Abfrage nicht anpassen, erhalten Sie entweder eine Exception oder, schlimmer noch, ein korrekt erstelltes DTO mit vertauschten Werten. Nichts Persönliches: JPQL ist einfach kein Gedankenleser.
Der dritte Punkt betrifft null. Wenn Sie eine Spalte auswählen, die potenziell null sein kann, und im DTO verwenden Sie einen primitiven Typ wie long oder int, riskieren Sie ein Problem beim Erstellen des Objekts. Daher werden in Projektions-DTOs fast immer Wrapper-Typen (Long, Integer) verwendet, oder die Abfrage wird so entworfen, dass null unmöglich ist.
Schließlich ist es hilfreich zu bedenken: Eine Projektion ist ein „Konstruktor pro Zeile“. Wenn Sie 500 Zeilen zurückerhalten, ruft Hibernate 500 Mal den Konstruktor Ihres DTO auf. Daher sollte das DTO leicht sein: ohne Logik, ohne Datenbankzugriffe (ja, das wird manchmal versucht), ohne Seiteneffekte. Es ist einfach ein Datencontainer.
4. Spring Data JPA: Repository-Methode, die sofort DTO zurückgibt
Wenn wir „JPQL“ sagen, stellen sich viele die Arbeit mit EntityManager vor. Das ist normal: Unter der Haube sitzt tatsächlich EntityManager, und Spring Data JPA ist ein bequemer Überbau. Aber im Übungsprojekt möchten wir, dass das Lesen möglichst transparent ist, und die class-basierte Projektion fügt sich gut in @Query im Repository ein.
Beginnen wir mit dem Produkt. Im ProductRepository fügen wir eine Methode hinzu, die List<ProductListRow> zurückgibt. Wichtig: Wir geben ein DTO zurück, keine Entität – das ist der „Lese-Vertrag“.
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
where p.status = :status
order by p.name
""")
// Das Repository gibt sofort ein Read-Modell zurück, keine managed-Entität
List<ProductListRow> findListRowsByStatus(ProductStatus status);
Hier haben wir einen Textblock (""" ... """) verwendet, damit die Abfrage lesbar ist wie in der normalen Welt. Ja, das ist eine Kleinigkeit, aber im echten Leben ist die Lesbarkeit der Abfrage die halbe Miete. Die andere Hälfte ist das SQL-Trace, aber das ist unsere Standardreligion.
Jetzt die wichtige Frage: Wo genau soll diese Logik aufgerufen werden? Normalerweise erstellen wir für Lese-Anwendungsfälle eine Service-Methode (oder Query-Service) mit @Transactional(readOnly = true), damit das Lesen stabil und günstig ist. Wir haben den ReadOnly-Ansatz bereits früher als lokale Optimierung und Disziplin besprochen: Wenn wir definitiv nicht schreiben, tun wir auch nicht so, als ob wir schreiben.
Beispiel im Stil „Lesen einer Liste für das Admin-Panel“:
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true) // Wichtig: betont, dass es reines Lesen ist
public List<ProductListRow> listActiveProductRows() {
// DTO-Projektion: keine managed-Entitäten werden zurückgegeben
return productRepository.findListRowsByStatus(ProductStatus.ACTIVE);
}
In dieser Methode haben Sie jetzt keine managed-Entitäten, die „leben und sich aktualisieren wollen“. Sie haben einfach eine Liste von Zeilen.
Projektion mit Join
Die Bestellliste ist ein klassisches Beispiel, bei dem ein Feld aus einer verknüpften Entität benötigt wird. In der Entität PurchaseOrder gibt es eine Referenz auf Customer (höchstwahrscheinlich ManyToOne), aber in der Listentabelle benötigen wir von Customer nur eine E-Mail. Wenn wir List<PurchaseOrder> lesen und später im UI/Service order.getCustomer().getEmail() aufrufen, erhalten wir leicht N+1. Das können wir bereits diagnostizieren, und wir müssen diesen Schmerz nicht wiederholen.
In der DTO-Projektion lösen wir das ehrlich: Wir machen einen join und wählen c.email in einer einzigen Abfragezeile aus.
import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
@Query("""
select new com.example.commerce.orders.dto.OrderListRow(
o.id, o.orderNumber, c.email, o.status
)
from PurchaseOrder o
join o.customer c
order by o.id desc
""")
// Dies ist ein Join für Spalten in der Ergebnismenge, kein Laden des Entitätsgraphen
List<OrderListRow> findOrderRows();
Hier ist es wichtig, die Nuance zu verstehen: Das ist kein JOIN FETCH, und wir brauchen es nicht. JOIN FETCH dient zum Laden des Entitätsgraphen in den Speicher als managed-Objekte. Wir geben aber keine Entitäten zurück. Wir machen einfach einen Join in SQL, um die benötigten Spalten zu holen. Das Ergebnis ist eine einzige Abfrage ohne N+1, und wir ziehen keinen managed-Graphen dorthin, wo er nicht benötigt wird.
Wenn Sie jetzt fragen möchten: „Kann man auch einen Join auf eine Kollektion machen und ein DTO mit einer eingebetteten Liste bauen?“ – Ja, das ist aber eine ganz andere Komplexitätsstufe, und bei Listen-Lese-Fällen machen wir das normalerweise nicht. Wir halten die Liste flach und überlassen die Details einem anderen Anwendungsfall (Karte/Detailansicht).
### Alternative: EntityManager
Manchmal ist es hilfreich zu sehen, dass das Ganze auch ohne Spring Data funktioniert. Einfach damit im Kopf kein Gefühl bleibt, „die Annotation hat alles selbst gemacht“. Unter der Haube sieht es etwa so aus:
import com.example.commerce.catalog.dto.ProductListRow;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.List;
@PersistenceContext
private EntityManager em;
public List<ProductListRow> listRows() {
// TypedQuery: Wir sagen sofort, welche DTO-Klasse wir erwarten
return em.createQuery("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
""", ProductListRow.class).getResultList();
}
In unserem Kurs werden wir meistens Repositories verwenden, weil das der Standardstil von Spring-Anwendungen ist. Aber das Verständnis „was da unten läuft“ hilft, nicht zu jemandem zu werden, der an JPA wie an Magie glaubt. Und, zur Erinnerung: In diesem Deep-Dive-Kurs beschäftigen wir uns genau damit, die Magie aus der Persistenzschicht zu vertreiben.
5. Wie Hibernate das DTO zusammenbaut
Wenn Sie eine JPQL-Abfrage mit einem Konstruktorausdruck ausführen, durchläuft Hibernate trotzdem seinen üblichen Weg: Parsen von JPQL, Erstellen von SQL, Vorbereiten des Statements, Round-Trip zur Datenbank, Empfangen des ResultSet. Aber es gibt einen grundlegenden Unterschied am Ende: Statt einer Entitätsmaterialisierung erstellt es Ihr DTO.
Es ist wichtig, das direkt auszusprechen: Das DTO wird nicht managed, denn der managed-Status betrifft Entitäten, die Identity Map, die Verbindung zu einer Tabelle mit einem Schlüssel. Ein DTO ist ein „Schnappschuss“ der Daten zum Zeitpunkt der Abfrage. Hibernate ist nicht verpflichtet (und wird es auch nicht tun), Änderungen am DTO zu verfolgen, da es nicht weiß, wie diese Änderungen zurück in die Tabelle abgebildet werden sollen. Und ehrlich gesagt, ist das besser so: weniger Überraschungen.
Stellen wir uns vor, welches SQL für ProductListRow erzeugt wird (die genaue Form kann je nach Aliasen leicht abweichen, aber der Sinn bleibt):
-- Schmaler SELECT: nur die Spalten, die für das DTO benötigt werden
select p1_0.id, p1_0.sku, p1_0.name
from product p1_0
where p1_0.status=?
order by p1_0.name
Beachten Sie, was hier fehlt. Es gibt keine Spalten description, keinen Join zu product_details, keine Kategorien. Wir haben nicht darum gebeten – Hibernate schleppt nichts mit. Und das ist genau der Fall, in dem „nicht mitschleppen“ ein Kompliment ist.
Für die Bestellliste mit Join auf Customer ergibt sich etwa Folgendes:
-- Join ist nötig, um die E-Mail des Kunden in einer Ergebniszeile zu haben (ohne N+1)
select o1_0.id, o1_0.order_number, c1_0.email, o1_0.status
from purchase_order o1_0
join customer c1_0 on c1_0.id=o1_0.customer_id
order by o1_0.id desc
Das bedeutet, wir erhalten die Daten für die Tabelle mit einer einzigen Abfrage. Das ist oft das Ziel: keine „lebenden“ Entitäten laden, wo ein „Schnappschuss“ ausreicht.
5.1. Wichtige Anmerkung zum Flush
Hier gibt es eine Feinheit, die die Projektion unerwartet mit dem Thema Flush aus Tag 4 verbindet. Selbst wenn Sie ein DTO lesen, führen Sie trotzdem eine JPQL-Abfrage aus. Und Hibernate hat bei FlushMode.AUTO das Recht (und manchmal die Pflicht), einen Flush vor der Abfrage durchzuführen, damit die Abfrage den korrekten Datenstand sieht.
Das Szenario ist also möglich: Sie haben innerhalb der Transaktion eine managed-Entität geändert (versehentlich oder absichtlich), dann eine Methode aufgerufen, die eine Liste über DTO-Projektion liest, und plötzlich sehen Sie in den Logs vor dem SELECT ein UPDATE. Das ist kein Fehler der Projektion. Es ist der Flush-Zyklus. Das DTO hat damit nichts zu tun – die Abfrage wurde zum Auslöser der Kontextsynchronisation.
Wenn Sie jetzt denken: „Also schützt mich das DTO nicht vor unerwarteten UPDATEs“ – es schützt, aber im richtigen Sinne. Das DTO schützt Sie davor, dass Sie für das Lesen eine managed-Entität mitziehen und diese versehentlich im selben Lesevorgang ändern. Aber wenn Sie in derselben Transaktion tatsächlich Daten geändert haben, ist der Flush ein normaler Mechanismus, der die Korrektheit der Abfragen sicherstellt.
In readOnly=true-Transaktionen und in „reinen Lese“-Szenarien tritt das normalerweise nicht auf, weil Sie nichts ändern. Daher bleibt die Disziplin der ReadOnly-Methoden nützlich.
6. Typische Fehler bei class-basierter DTO-Projektion
Fehler Nr. 1: DTOs werden zu „beschnittenen Entitäten“ und man schleppt alles Mögliche mit.
So etwas beginnt normalerweise harmlos: „Ach, fügen wir noch den Preis hinzu“, dann „und der Status wird auch nützlich sein“, dann „und die Beschreibung soll auch dabei sein, falls nötig“, und endet damit, dass das DTO von der Größe und Bedeutung her eine zweite Entität ohne ihre Garantien wird. Die Lösung ist sehr langweilig und sehr effektiv: Jedes DTO sollte einen Namen haben, der an den Anwendungsfall gebunden ist (ProductListRow, OrderListRow), und die Vereinbarung, dass es genau das enthält, was die spezifische Tabelle/Liste zeigt.
Fehler Nr. 2: Die Reihenfolge der Argumente in select new ... stimmt nicht mit dem Konstruktor/Record überein.
JPQL kann nicht „nach Sinn raten“, und Hibernate wird nicht die Gedanken des Entwicklers lesen. Wenn Sie das DTO geändert, aber die Abfrage nicht angepasst haben, erhalten Sie entweder eine Exception oder, schlimmer noch, ein korrekt erstelltes DTO mit vertauschten Werten. In Übungsprojekten sind solche Bugs besonders tückisch: Die Anwendung „funktioniert“, aber die Daten in der Tabelle sind seltsam. Eine gute Gewohnheit ist, nach einer Änderung der Projektion sofort das SQL-Trace zu prüfen und ein paar Ergebniszeilen mit eigenen Augen zu überprüfen.
Fehler Nr. 3: Verwendung von primitiven Typen und dann ein Problem mit null.
In Listen gibt es oft nullable Felder: Zum Beispiel kann customerEmail null sein, wenn die Bestellung technisch ist oder der Kunde optional (in der realen Welt kommt alles vor). Wenn Sie das in ein record OrderListRow(long id, ...) stecken, kann Hibernate kein Objekt für eine Zeile mit null erstellen. Für Projektionen ist es fast immer sicherer, mit Wrapper-Typen (Long, Integer) zu beginnen und erst dann, wenn der Vertrag stabil ist, das Modell zu verschärfen.
Fehler Nr. 4: Man erwartet von einem DTO das Verhalten einer Entität (Lazy Loading, Navigation über Verknüpfungen, „gib mir den ganzen Customer“).
Ein DTO ist keine Entität, und sein Zweck besteht gerade darin, keinen Graphen mit sich zu führen. Wenn Sie sich dabei ertappen, row.getCustomer().getEmail() schreiben zu wollen, ist das ein Zeichen dafür, dass Sie entweder die Ergebnisform nicht zu Ende gedacht haben oder versuchen, in einem einzigen Lese-Fall sowohl Liste als auch Details unterzubringen. Halten Sie die Liste flach. Details – in einer separaten Abfrage und einem separaten Ergebnistyp.
Fehler Nr. 5: Man denkt, dass eine Projektion „immer schneller“ ist, und hört auf, auf SQL zu achten.
Eine Projektion reduziert zwar oft die Breite des SELECT und verringert die Nebenwirkungen des ORM. Aber wenn Sie eine Abfrage mit drei überflüssigen joins, einem schlechten where oder ohne Index schreiben, bleibt sie trotzdem langsam – sie gibt nur DTOs statt Entitäten zurück. In unserem Kurs ist das fast der Hauptgedanke: Die Form des Ergebnisses ist wichtig, aber das SQL-Trace und die Beobachtbarkeit sind noch wichtiger. Hibernate kann man nicht „überreden“, schnell zu sein, wenn die Abfrage ungünstig aufgebaut ist.
GO TO FULL VERSION