1. Grenzen von projections
Projections wirken einfach: „Was gibt es da schon groß – nicht Product, sondern ProductRow zurückgeben.“ Aber in Wirklichkeit bauen Sie einen Vertrag zwischen drei Welten: Ihrem Java-Code (mit Interfaces, Konstruktoren und records), dem JPQL-String (der nicht vom Java-Compiler geprüft wird) und dem Ergebnis, das der JPA-Provider (Hibernate) liefert. An den Schnittstellen dieser Welten verstecken sich Fehler besonders gern.
Seien wir ehrlich: Wenn Sie @Query(""" ... """) schreiben, erzeugen Sie einen String. Ein String weiß nicht, was „Feld in einer Entität umbenannt“, „Argumentreihenfolge in einem record geändert“ oder „Alias mit falschem Namen geschrieben“ bedeutet. Daher sind fast alle Probleme mit projections keine „Kompilierfehler“, sondern „es lief alles gut, bis es in der Produktion schiefging“. Und ja, wir lernen so, dass dies im Studienprojekt passiert – nicht um 3 Uhr nachts.
Um es sich besser zu merken, behalten Sie ein einfaches Bild im Kopf: Bei interface-based projections ist die Schwachstelle die Namen (getter ↔ alias), bei DTO/Record-Projections ist die Schwachstelle der Konstruktor (Positionen und Typen der Argumente). Und es gibt immer eine dritte Problemklasse: „Wir haben vergessen, woher wir die Daten nehmen“ – die Abfrage beschreibt keinen join, aber die projection fordert plötzlich ein Feld aus einer verknüpften Entität.
Skizzieren wir ein Diagramm, damit das Gefühl, „woher die Werte in einer projection überhaupt kommen“, visuell wird:
flowchart TD
Repo["Repository method
return type = projection"]
Q["JPQL / derived query"]
H["Hibernate (JPA provider)"]
R["Result rows / tuples"]
M["Spring Data mapping
(projection)"]
P["Interface proxy
oder DTO/record"]
Repo --> Q --> H --> R --> M --> P
Der Rest der Vorlesung handelt davon, wie man diese Pipeline vorhersagbar zum Laufen bringt – nicht „naja, manchmal irgendwie null“.
Weiter geht es nicht mehr um die Frage, wie man eine weitere projection schreibt, sondern wo genau sie scheitert und nach welchem Kriterium man die Form für den use case auswählt.
2. Name gegen Konstruktor: Wo unterschiedliche projections scheitern
Die beiden Arbeitsformen von projections haben unterschiedliche Schwachstellen. Es ist hilfreich, sie in einer kompakten Karte zu behalten, um später nicht anhand der Symptome raten zu müssen.
| Art der Projection | Worauf stützt sich das Mapping | Schwachstelle | Wann üblich |
|---|---|---|---|
| Interface-based | getter → property/alias | Namen der getter, alias in @Query, unerwartete geschachtelte Properties | Wenn ein sehr leichter Read-Kontrakt ohne separate Klasse benötigt wird |
| DTO/Record über select new | constructor expression | FQDN, Reihenfolge und Typen der Argumente, Alias innerhalb von select new | Wenn ein expliziter benannter Ergebnistyp benötigt wird |
Für die interface-based projection sind die Namen entscheidend. Spring Data muss erkennen, welcher Wert in getOrderNumber() oder getTotalAmount() landen soll. Daher stützt sich eine derived query üblicherweise auf die Eigenschaftsnamen der Entität, und in @Query schreibt man besser explizit einen alias, der mit dem erwarteten Eigenschaftsnamen übereinstimmt.
Bei der DTO/Record-Projection ist die Logik eine andere: select new mapped das Ergebnis nicht nach Namen, sondern ruft einen Konstruktor auf. Daher sind der vollständige Klassenname, die Argumentreihenfolge und ihre Typen wichtig. Der alias innerhalb von select new hilft hier nicht: An den Konstruktor werden Werte übergeben, keine benannten Spalten.
3. Wann interface und wann DTO/record verwenden?
Die Wahl ist keine ideologische, sondern eine rein praktische.
Interface-based projection ist üblicherweise nützlich, wenn ein sehr leichter Read-Kontrakt benötigt wird: ein paar getter, ein einfacher Listen-/Seiten-use case, eine derived query oder eine kurze @Query, und eine separate benannte Klasse im Projekt bringt keinen merklichen Vorteil. Es ist eine schnelle Möglichkeit, dem Repository zu sagen: „gib genau diese Felder und tue nicht so, als wärst du eine Entität“.
DTO/Record-Projection ist praktischer, wenn das Lesemodell zu einer eigenständigen Codeeinheit wird: Man möchte es explizit benennen, ruhig durch die Serviceschicht reichen, loggen, testen und in der Nähe einer bestimmten Funktion halten. Deshalb tauchen in Projektszenarien des Mini-Shops ganz natürlich ProductCatalogRow und OrderSummaryRow auf.
Beide Varianten lösen dasselbe Problem – die entity nicht dorthin zu schleppen, wo ein schmales Ergebnis ausreicht. Wenn die projection anschwillt zu „fast die ganze Entität“, ist das eher ein Signal nicht für den Sieg des einen Ansatzes über den anderen, sondern dafür, dass der use case schlecht abgegrenzt ist.
4. Projections mit Beziehungen: join und Felder
Die heikelste Stelle beginnt, wenn die Lesestring-Felder aus mehreren Entitäten benötigt. Eine typische Bitte klingt so: „Im Katalog des Produkts soll nicht nur der Name des Produkts, sondern auch der Name der Kategorie angezeigt werden.“ Wir haben Product mit Category über @ManyToOne verknüpft – das ist wunderbar … aber projection kann keine Gedanken lesen. Wenn Sie categoryName erhalten möchten, müssen Sie der Abfrage explizit zeigen, woher dieses Feld stammt.
Bei record-Projektionen ist das meist ganz ehrlich: Sie wollen drei Felder – wählen Sie drei Felder.
// Hier ist die Absicht einfach: Wir wollen Felder aus Product + ein Feld aus der verknüpften Category lesen
public record ProductWithCategoryRow(
String sku,
String productName,
String categoryName
) {
}
Abfrage:
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Wichtig: Das Feld categoryName wird aus c.name bezogen, daher ist ein join erforderlich
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductWithCategoryRow(
p.sku, p.name, c.name
)
from Product p
join p.category c
where p.id = :id
""")
Optional<ProductWithCategoryRow> findRowById(@Param("id") Long id);
}
Hier kommt es auf die Idee an, nicht auf die Syntax: join p.category c macht c in der Abfrage verfügbar, und nur deshalb können wir c.name auswählen.
Bei der interface-based projection ist die Logik ähnlich, nur kommt die Disziplin der alias’ hinzu. Zum Beispiel:
// Die Alias' in der Abfrage unten müssen mit den Eigenschaften übereinstimmen: sku, productName, categoryName
public interface ProductWithCategoryView {
String getSku();
String getProductName();
String getCategoryName();
}
Und die Abfrage:
// Wichtig: p.name wird in productName umbenannt – und der Alias muss genau productName sein
@Query("""
select p.sku as sku,
p.name as productName,
c.name as categoryName
from Product p
join p.category c
where p.id = :id
""")
Optional<ProductWithCategoryView> findViewById(@Param("id") Long id);
Beachten Sie einen subtilen Punkt: In der Entität heißt das Feld name, und in der projection haben Sie beschlossen, es productName zu nennen (damit es lesbarer ist). Das ist in Ordnung, aber dann muss der alias ebenfalls genau productName sein, nicht name. Das heißt, Sie wählen p.name as productName aus. Das ist kein „unnötiges Geschwätz“, sondern eine Möglichkeit, den Lesekontrakt explizit zu machen.
Eine weitere praktische Erkenntnis: Sobald die projection Daten aus mehreren Entitäten umfasst, wird die Abfrage etwas „manueller“. Und das ist in Ordnung. Projections sind genau der Moment, in dem es besser ist, etwas expliziter zu sein, als auf „es wird schon klappen“ zu hoffen.
5. projection und entity: Rollen und Erwartungen
Hier ist es hilfreich, einen einfachen Unterschied im Kopf zu behalten. Eine Entity lebt nach den Regeln des ORM: Sie hat @Id, Lebenszyklus, Beziehungen und nimmt am persistence context teil. Eine Projection ist ein Leseergebnis, kein Teil dieses Lebenszyklus.
Daraus ergeben sich schnell praktische Konsequenzen. Eine Projection kann man nicht aufrichtig save(...), von ihr kann man kein dirty checking, cascade oder den Effekt „ich habe ein Feld geändert – die Datenbank errät es selbst“ erwarten. Selbst wenn in der projection zufällig dieselben Felder wie in Product landen, ist sie dennoch kein Product.
Daher ist die Regel gelassen: Wenn das Szenario Daten liest, um den Zustand des Modells zu ändern, wird in der Regel eine entity benötigt. Wenn das Szenario eine schmale Datenform für eine Liste, Zusammenfassung oder eine leichte Karte wünscht, ist die projection ehrlicher und günstiger. Und wenn die projection zu „fast der ganzen Entität“ anschwillt, haben Sie das Problem nur umbenannt.
6. Spickzettel zur Überprüfung der projection
Wenn Sie anfangen, intensiv projections zu schreiben, wünscht man sich einen „inneren Checker“: Habe ich alles richtig zusammengeführt? Hier eine kompakte Tabelle, die wie gesunder Menschenverstand funktioniert, nicht wie ein Ritual.
| Was Sie verwenden | Wie die Felder verknüpft werden | Was übereinstimmen muss | Was normalerweise schiefgeht |
|---|---|---|---|
| Interface-based projection + derived query | Spring Data nimmt Eigenschaften aus dem entity-Modell | Namen der getter ↔ Eigenschaften der entity | Seltsame getter-Namen, Versuch „schön zu machen“ und Erhalten von getProduct_title() |
| Interface-based projection + @Query | Über die alias’ des Ergebnisses | alias ↔ Eigenschaftsname (getTotalAmount() → totalAmount) | alias stimmt nicht überein, as ... vergessen, null erhalten |
| DTO/Record projection + select new | Nach Position der Argumente | Reihenfolge und Typen ↔ Konstruktor von DTO/Record | Felder im record vertauscht, Typ geändert, Ausdruck falschen Typs hinzugefügt |
| Beliebige Projection mit Beziehungen | Sie selbst beschreiben den Leseweg | Die Abfrage muss einen Pfad (join) zu den benötigten Daten haben | Versuch c.name zu nehmen, aber keinen join gemacht, oder den alias verwechselt |
Und zwei „menschliche“ Regeln, die meist mehr retten als die Tabelle. Erstens: Eine projection sollte klein sein und nach dem use case benannt sein (z. B. OrderSummaryRow), nicht nach der Entität (OrderDtoButNotDto). Zweitens: Wenn Sie ein Feld in der entity umbenannt oder einen record geändert haben – denken Sie daran, dass Ihre Abfragen Strings sind und „Refactoring alles selbst macht“ nicht zutrifft. Die IDE hilft, aber sie ist nicht allmächtig.
7. Typische Fehler bei der Arbeit mit projections
Fehler Nr. 1: alias in @Query stimmt nicht mit dem erwarteten Eigenschaftsnamen des Interfaces überein.
Das ist der Klassiker bei interface-based projections. Die Abfrage gibt o.orderNumber as number zurück, aber das Interface erwartet getOrderNumber(). Die Folge ist entweder null-Werte oder Sie vermuten, dass Hibernate Sie „nicht respektiert“. Hibernate hat damit nichts zu tun: Sie haben den Lesekontrakt an zwei Stellen mit unterschiedlichen Wörtern beschrieben.
Fehler Nr. 2: Versuch, die Lesbarkeit zu „verbessern“ und versehentlich die Argumentreihenfolge in select new zu vertauschen.
Bei record-Projektionen passiert das besonders leicht: Sie haben die Reihenfolge der Komponenten im record geändert (weil „es schöner aussieht“), aber die Abfrage unverändert gelassen. Java hat alles kompiliert, die Anwendung gestartet, und im Moment der Ausführung der Abfrage stellt sich plötzlich heraus, dass der Konstruktor nicht übereinstimmt. Das ist der Fall, in dem „schöner“ unerwartet „teurer“ wird.
Fehler Nr. 3: Verwendung von alias’ innerhalb der Argumentliste von select new.
In select new sind die Argumente positionsbasiert. p.sku as sku innerhalb der runden Klammern zu schreiben, ist nicht nötig. Oft endet das entweder in einem Parsing-Fehler der Abfrage oder einfach in unnötiger Verwirrung: Man denkt, der alias würde am Mapping teilnehmen. Tut er nicht. Beteiligt sind Position und Typ.
Fehler Nr. 4: projection verlangt ein Feld aus einer verknüpften Entität, aber die Abfrage enthält keinen join.
Sie haben ProductWithCategoryRow(sku, productName, categoryName) erstellt und in der Abfrage schreiben Sie c.name, aber c ist nirgendwo deklariert. In der SQL-Welt wäre das wie „im SELECT ein Feld aus einer Tabelle, die Sie nicht eingebunden haben“. In JPQL ist die Logik dieselbe. Beziehungen in der entity bedeuten nicht, dass der join genau so von selbst passiert, wie Sie es brauchen.
Fehler Nr. 5: Versuch, eine projection als entity zu verwenden.
Manchmal möchte man „ein bisschen Daten korrigieren“ und dann save(). Aber projection ist kein Persistenzmodell. Sie lebt nicht im ORM-Zyklus. Man kann sie nicht kaskadieren, nicht „aktualisieren und vergessen“ und nicht erwarten, dass sie sich wie Product verhält. Es ist eine „Lese-Schnappschuss“ und sollte auch so behandelt werden.
GO TO FULL VERSION