1. DTO/Record statt interfacebasierter Projektion
Wenn Sie zum ersten Mal eine interfacebasierte Projektion sehen, entsteht das Gefühl: „Oh, super! Jetzt erstelle ich ein paar Interfaces und alles wird schön.“ Und tatsächlich ist es für einfache Fälle ein hervorragendes Werkzeug: Sie beschreiben nur die benötigten Getter, Spring Data JPA liefert ein „leichtes“ Ergebnis, und Sie schleppen keine ganze Entity in den Speicher.
Aber in einem realen Projekt (auch in unserem Mini-Shop) entsteht relativ schnell ein anderer Bedarf: Man möchte einen explizit benannten Ergebnistyp haben, der als normales Objekt weitergegeben, protokolliert, verglichen, getestet werden kann, und bei dem die IDE mit Autovervollständigung nicht versucht zu „erraten“, was unter dem Proxy verborgen ist. Genau hier ist es sinnvoll, auf DTO- oder Record-Projektionen umzusteigen.
Der interfacebasierte Ansatz hat noch einen weiteren alltäglichen Nachteil: Ein Interface wird sehr leicht zu einem „universellen Staubsauger für alles“, wenn man anfängt, Getter „für alle Fälle“ hinzuzufügen. Und Sie gelangen unmerklich wieder zu der Situation: „Wir geben fast die gesamte Entität zurück, nur mit anderen Worten.“ Bei DTO/Record ist es psychologisch meist einfacher: Wenn Sie schon einen separaten Typ erstellen, sollte dieser klein und eindeutig sein, sonst lässt einen das Gewissen (oder der Teamleiter) nicht ruhig schlafen.
In den Begriffen von Spring Data nennt man dies classbasierte Projektionen (DTOs), und die Dokumentation sagt klar, dass dies eine normale Variante von Projektionen ist: separate Werttypen, die als Abfrageergebnis verwendet werden, ohne Proxy, im Gegensatz zu Interface-Projektionen.
2. Java Record als „ideale Box“ für das Lesemodell
Wenn Sie das Wort Record hören, könnte es scheinen, dass es sich um „ein neues Ding handelt, mit dem man im Vorstellungsgespräch prahlt“. In Wirklichkeit ist es einfach eine sehr bequeme Form für kleine unveränderliche Datenobjekte. Und in Projektionen fühlt sich Record wie zu Hause: Er ist kurz, transparent, und Sie schreiben keine Unmengen von Boilerplate-Code.
Es ist wichtig, die richtige Assoziation zu bekommen: Record ist in unserem Kontext eine Ergebniszeile (Row), kein Verhaltensobjekt. Wie ein Kassenbon im Geschäft: „SKU, Preis“. Er tut nichts, er teilt einfach Tatsachen mit.
Spring Data betont in der Dokumentation ausdrücklich, dass Java-Records besonders gut für DTO-Typen geeignet sind, da sie Value Semantics haben: Felder sind private final, equals()/hashCode()/toString() werden automatisch generiert, und insgesamt sind sie praktische Datenträger.
Eine einfache Record-Projektion für unseren Katalog (z. B. „Produktpreise nach Status“) könnte so aussehen:
import java.math.BigDecimal;
// Read-Modell: Dies ist KEINE Entität, sondern eine "Ergebniszeile" aus der Abfrage
public record ProductPriceRow(String sku, BigDecimal price) {
// Der Record hat einen kanonischen Konstruktor (sku, price),
// und genau dieser wird von JPQL über `select new` aufgerufen.
}
Beachten Sie die angenehme Kleinigkeit: Der Record hat bereits einen „Standard“-Konstruktor (kanonisch), und dieser ist perfekt für JPQL select new, weil wir genau einen Konstruktor mit Argumenten benötigen.
3. JPQL select new: Wie eine Abfrage DTO/Record direkt im laufenden Betrieb erstellt
Die wichtigste Idee dieser Vorlesung: JPQL kann nicht nur Entitäten oder einzelne Felder auswählen, sondern auch Instanzen Ihrer Klasse direkt im Abfrageergebnis erzeugen. Dies geschieht über einen Constructor Expression – eine Konstruktion der Form:
select new com.example.SomeDto(x, y, z) ...
Hibernate (und JPA allgemein) beschreibt dies so: select new „verpackt“ die Abfrageergebnisse in eine benutzerdefinierte Java-Klasse anstelle eines Arrays, und dafür muss die Klasse mit ihrem vollständigen Namen angegeben werden und einen passenden Konstruktor besitzen.
Und hier gibt es sofort zwei kritische Konsequenzen, die es wichtig ist, gelassen und ohne Drama zur Kenntnis zu nehmen.
Erste Konsequenz: In JPQL muss der Fully Qualified Name (FQDN) der DTO/Record-Klasse geschrieben werden, also inklusive Paket. Nicht „ProductPriceRow“, sondern „com.example.shopdatajpa.catalog.query.ProductPriceRow“. Das geschieht nicht aus Boshaftigkeit – der JPQL-String lebt einfach getrennt von den Java-Imports.
Zweite Konsequenz: Das Ergebnis von select new ist keine verwaltete Entität (managed entity). Selbst wenn Sie die DTO zufällig genauso nennen wie die Entity, und selbst wenn es eine Entity-Klasse wäre (was man nicht tun sollte), wird das Ergebnisobjekt nicht Teil des Persistenzkontexts und beginnt nicht magisch, sich bei Feldänderungen zu „speichern“. Es ist einfach ein erstelltes Datenobjekt. Hibernate warnt gesondert davor: Solche Instanzen sind keine verwalteten Entitäten und keiner Session zugeordnet.
Um select new nicht als Magie aufzufassen, ist es hilfreich, sich das folgende Schema vorzustellen:
flowchart TD
A[JPQL-Abfrage] --> B[SQL an die Datenbank]
B --> C[Spalten im ResultSet]
C --> D[Aufruf des DTO/Record-Konstruktors]
D --> E[Fertiges Objekt zum Lesen]
Das bedeutet: „ORM erstellte SQL → Datenbank gab Werte zurück → JPA rief Ihren Konstruktor auf.“ Keine Telepathie. Nur eine saubere Verpackung des Ergebnisses.
4. Beispiel 1: ProductPriceRow über select new im ProductRepository
Jetzt werden wir ein kleines, aber sehr aussagekräftiges Stück unseres Mini-Shops erstellen. Stellen wir uns vor, dass es im Katalog ein Szenario gibt: „Nur Preise von Produkten mit einem bestimmten Status anzeigen“, zum Beispiel für einen internen Bericht oder für die Neuberechnung von Rabatten. Wir brauchen weder ProductDetails, noch die Kategorie, noch die ID, sondern nur ein Wertepaar.
Erstellen des Record im richtigen Paket
Gemäß der Projektarchitektur halten wir solche Lesemodelle in der Nähe des Querying-Codes der jeweiligen Funktion. Daher ist es logisch, den Record hier zu platzieren:
com.example.shopdatajpa.catalog.query
import java.math.BigDecimal;
// Read-Modell für einen konkreten Use Case: „SKU + Preis“
// (in der Regel halten wir DTO/Record-Ergebnisse getrennt von Entitäten)
public record ProductPriceRow(String sku, BigDecimal price) {
}
Schreiben der @Query mit select new
Jetzt fügen wir eine Methode im ProductRepository hinzu. Wichtig ist, dass die Abfrage über das Entitätenmodell formuliert wird (Product p, p.sku, p.price), wie wir es am Tag über JPQL gemacht haben, und dabei im select einen Record erzeugen.
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductPriceRow;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
// Die Repository-Methode gibt genau das Lesemodell zurück, nicht die Entity:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.sku, p.price)
from Product p
where p.status = :status
order by p.sku
""")
List<ProductPriceRow> findPriceRowsByStatus(@Param("status") ProductStatus status);
// Wichtig:
// 1) FQDN in `select new` ist erforderlich (Java-Imports funktionieren hier nicht)
// 2) Die Argumentreihenfolge muss mit dem Konstruktor des Records übereinstimmen
// 3) :status ist ein benannter Parameter, damit die Abfrage bei Refactoring besser überlebt
Hier sind absichtlich mehrere Disziplinen gleichzeitig sichtbar:
Wir geben List<ProductPriceRow> zurück und legen damit den Lesevertrag fest. Die Methode handelt nicht mehr „über Entitäten“, sondern über Preise.
Wir schreiben den vollständigen Namen des Record in JPQL, weil @Query ein String ist und Java-Imports hier nicht helfen.
Wir verwenden den benannten Parameter :status, damit die Abfrage gut lesbar bleibt und Refactoring besser übersteht.
Wie das im Service aussieht
Jetzt kann der Service direkt mit dem Lesemodell arbeiten, ohne den Zwischenschritt „Entitäten lesen → manuell zwei Felder nehmen“.
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductPriceRow;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CatalogQueryService {
private final ProductRepository productRepository;
public CatalogQueryService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ProductPriceRow> findActivePrices() {
// Der Service gibt genau das zurück, was der Use Case benötigt:
// eine Liste „SKU + Preis“ für aktive Produkte, ohne Entity und ohne überflüssige Felder.
return productRepository.findPriceRowsByStatus(ProductStatus.ACTIVE);
}
}
Beachten Sie die „leckere“ architektonische Feinheit: Der Service sagt jetzt explizit „Ich gebe eine Preisliste zurück“, nicht „Hier sind die Entitäten, macht mal selbst“. Genau das ist der Grund, warum wir überhaupt mit Projektionen begonnen haben.
5. Beispiel 2: OrderTotalRow über select new
Record ist ein hervorragendes Werkzeug, aber manchmal möchte man aus bestimmten Gründen eine normale Klasse. Zum Beispiel, wenn Sie die Getter etwas anders benennen, eine Formatierungsmethode hinzufügen möchten (obwohl das für ein Lesemodell bereits fragwürdig ist), oder Sie sich noch nicht mit Records angefreundet haben. In Spring Data ist das ebenfalls eine normale classbasierte Projektion.
Erstellen wir eine DTO für das Szenario „Bestellsumme kurz anzeigen“. Angenommen, wir benötigen orderNumber und totalAmount.
DTO-Klasse
import java.math.BigDecimal;
// DTO-Klasse als Lesemodell: speichert nur ausgewählte Felder, ohne ORM-Verhalten
public class OrderTotalRow {
private final String orderNumber;
private final BigDecimal totalAmount;
public OrderTotalRow(String orderNumber, BigDecimal totalAmount) {
// Diese Werte stammen aus `select new ... (o.orderNumber, o.totalAmount)`
this.orderNumber = orderNumber;
this.totalAmount = totalAmount;
}
public String getOrderNumber() {
return orderNumber;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
}
Solange Sie Junior sind, sollten Sie DTOs möglichst langweilig halten. Je weniger „Schlauheit“, desto weniger unerwartete Effekte.
Bestell-Repository: select new und Optional
Und jetzt deklarieren wir eine Methode im CustomerOrderRepository, die diese Projektion zurückgibt.
import com.example.shopdatajpa.ordering.query.OrderTotalRow;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
// Optional spiegelt hier die fachliche Realität wider: Die Bestellung könnte nicht existieren
@Query("""
select new com.example.shopdatajpa.ordering.query.OrderTotalRow(o.orderNumber, o.totalAmount)
from CustomerOrder o
where o.id = :id
""")
Optional<OrderTotalRow> findTotalRowById(@Param("id") Long id);
// Beachten Sie:
// - Wir geben DTO/Projektion zurück, nicht die Entity
// - FQDN ist innerhalb von `select new` erforderlich
// - Der Parameter :id ist ein benannter Parameter
Hier ist ein wichtiger Punkt: Die Ergebnisform (Optional) drückt aus: „könnte nicht existieren“. Und das ist viel ehrlicher, als null zurückzugeben und dann irgendwo an unerwarteter Stelle eine NullPointerException zu fangen.
6. Matching des Konstruktors für select new
Während die interfacebasierte Projektion oft an die Getter-Namen gebunden ist, ist select new an den Konstruktor gebunden. Und er liest nicht Ihre Gedanken. Er liest nur die Liste der Argumente.
Genau deshalb wiederholen sowohl die Hibernate- als auch die Spring Data-Dokumentation immer wieder den gleichen Gedanken mit anderen Worten: Es muss einen passenden Konstruktor (matching constructor) geben. Hibernate schreibt direkt, dass die Klasse einen geeigneten Konstruktor haben muss.
Damit Sie sich das leichter merken können, hier eine kleine Tabelle:
| DTO/Record erwartet | JPQL muss auswählen |
|---|---|
| new ProductPriceRow(String sku, BigDecimal price) | p.sku, p.price |
| new OrderTotalRow(String orderNumber, BigDecimal totalAmount) | o.orderNumber, o.totalAmount |
Ein sehr typischer „stiller“ Anfängerfehler ist es, versehentlich die Reihenfolge zu vertauschen:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.price, p.sku)
from Product p
""")
List<ProductPriceRow> broken();
// Dieser Code wird kompiliert (die Abfrage ist ein String),
// aber zur Laufzeit wird er fehlschlagen: Der Record hat keinen Konstruktor (BigDecimal, String) in dieser Reihenfolge.
Der Code wird kompiliert (weil es ein String ist), aber bei der Ausführung erhalten Sie einen Fehler, weil der Konstruktor des Records (BigDecimal, String) in dieser Reihenfolge nicht akzeptiert. Und das ist eine gute Lektion: JPQL wird später überprüft als Java-Code.
Es gibt noch eine weitere wichtige Nuance speziell für Constructor Expressions: Innerhalb der Argumente von select new dürfen keine Aliase verwendet werden. Für interfacebasierte Projektionen sind Aliase normal, weil sie helfen, Spalten mit Gettern abzugleichen. Aber für DTO Constructor Expressions sind sie unzulässig. Spring Data warnt gesondert: JPQL-Constructor-Expressions dürfen keine Aliase für die ausgewählten Elemente enthalten.
Das sollte man also nicht tun:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(
p.sku as sku, p.price as price
)
from Product p
""")
List<ProductPriceRow> nope();
// Aliase `as ...` innerhalb von `select new` zerstören den Constructor Expression:
// Hier werden Werte übergeben, keine "benannten Spalten".
In select new übergeben Sie Werte, keine „benannten Spalten“.
7. Aufbewahrung und Benennung von DTO/Record-Projektionen
Wenn im Projekt Projektionen auftauchen, hat ein Anfänger in der Regel zwei Szenarien.
Erstes Szenario: „Ich lege alle DTOs im Paket dto ab, weil das üblich ist.“ Nach einer Woche sind es 40 Klassen, von denen sich die Hälfte auf den Katalog bezieht, ein Viertel auf Bestellungen und ein weiterer Teil überhaupt nicht zuordenbar ist. Das ist eine klassische „Misc“-Sammlung.
Zweites Szenario (viel gesünder): Projektionen in der Nähe der Funktion und in der Nähe des Querying-Codes aufbewahren. In unserem Projekt passt das perfekt in catalog.query und ordering.query.
Auch bei der Benennung gibt es eine einfache alltägliche Disziplin. Wenn die Projektion einer Listenzeile entspricht, ist es oft praktisch, sie ...Row oder ...View zu nennen. Wenn es sich um ein „Datenstück für einen Bericht/eine Tabelle“ handelt, deutet Row sehr direkt an: Dies ist keine Entität, sondern eine „Ergebniszeile“. Genau diesen Stil haben wir bereits in den Beispielen verwendet (ProductPriceRow, OrderTotalRow).
Ein separates Wort zum Suffix Dto. Es ist nicht verboten. Aber in einem Data-Layer-Projekt klingt es manchmal zu sehr nach „Web-Layer“, weil viele das Wort DTO mit REST-Verträgen assoziieren. Unser Gedanke heute ist jedoch: „Es ist ein Lesemodell für einen Use Case innerhalb des Backends“, nicht unbedingt ein Transport nach außen. Daher sind Row/View/Summary psychologisch meist präziser.
8. Typische Fehler bei DTO/Record-Projektionen über select new
Fehler Nr. 1: Den Kurznamen der Klasse in JPQL schreiben und auf Imports hoffen.
In Java sind wir es gewohnt: Importiert und gut. Aber @Query ist ein String, der sein eigenes Leben lebt. Hibernate erwartet, dass in select new die Klasse mit vollqualifiziertem Namen angegeben wird, andernfalls kann er sie einfach nicht finden.
Fehler Nr. 2: „Passt fast“ zum Konstruktor – und das reicht schon.
select new erfordert Übereinstimmung in Reihenfolge und Typen der Argumente. Wenn Sie die Felder vertauschen oder den Typ ändern, erhalten Sie einen Laufzeitfehler. Hibernate sagt direkt, dass ein passender Konstruktor (matching constructor) benötigt wird.
Fehler Nr. 3: Aliase innerhalb von select new hinzufügen, wie bei interfacebasierten Projektionen.
Interface-Projektionen erfordern oft Aliase, um Getter zuzuordnen, und das kann Sie „daran gewöhnen“, überall as zu schreiben. Aber Spring Data warnt ausdrücklich, dass Aliase für Constructor Expressions unzulässig sind.
Fehler Nr. 4: Versuchen, das DTO/Record wie eine Entität zu behandeln.
Das Ergebnis von select new ist keine verwaltete Entität: Es unterliegt nicht dem Persistenzkontext und ist kein „lebendiges ORM-Objekt“. Es ist einfach ein Datenobjekt. Hibernate betont, dass solche Instanzen keiner Session zugeordnet sind.
Fehler Nr. 5: Ein DTO „für alle Lebenslagen“ erstellen und es in eine Halb-Entität verwandeln.
Sobald in einem DTO Felder „für alle Fälle“ auftauchen, hört es auf, eine Projektion zu sein, und wird zu einer neuen Version der Entität, nur ohne Annotationen. In diesem Moment ist es ehrlicher, entweder die Entität zurückzugeben oder zwei kleine Projektionen für zwei verschiedene Use Cases zu erstellen. Spring Data beschreibt DTO-Projektionen als Typen für ausgewählte Felder, das heißt, der Sinn liegt gerade in der Einschränkung.
GO TO FULL VERSION