1. Zwei Backoffice-Listen in Commerce Persistence Lab
Projection kann leicht unterschätzt werden als „na ja, man kann Felder abschneiden“. Aber in einer echten Liste ist das keine bloße Kosmetik, sondern eine sinnvolle Refactorierung mit sehr klarem Ergebnis. Wir nehmen zwei typische Szenarien: eine Produkttabelle und eine Bestelltabelle im Backoffice. In beiden Fällen benötigt der Bildschirmbenutzer (und der Code) nur wenige Spalten, nicht den gesamten Entitätsgraphen.
Legen wir das Ziel ganz bodenständig fest. Wir haben zwei „Listen“-Read-Use-Cases:
Erstens – die Product-Liste. Normalerweise werden in der Produkttabelle id, sku, name und manchmal der Status benötigt. Wir benötigen nicht, ProductDetails, Kategorien, zugehörige Entitäten zu schleppen, und schon gar nicht eine managed-Entität, die beim „einfachen Lesen“ versehentlich geändert werden könnte.
Zweitens – die PurchaseOrder-Liste. In einer typischen Bestelltabelle werden id, orderNumber, Status, Erstellungsdatum und Kunden-E-Mail benötigt. Beachten Sie: Die E-Mail liegt in Customer, d.h. die Bestellliste garantiert im einfachsten Fall fast, dass jemand „harmlos“ auf order.getCustomer().getEmail() zugreift und entweder ein N+1 erhält, oder eine Reihe von Lazy-Ladungen, oder die Versuchung, alles EAGER zu machen (und dann zu weinen).
Der entscheidende Punkt: Wir fügen nicht „einen weiteren DTO“ hinzu. Wir bilden ein Read-Modell für die jeweiligen Tabellen und verankern es auf Repository- und Query-Service-Ebene.
Damit die Idee nicht verschwimmt, kann man es sich so vorstellen:
flowchart TD
UI["Backoffice list screen
(conditional, table)"]
QS["Query Service
@Transactional(readOnly=true)"]
R["Repository
@Query + projection"]
DB[(PostgreSQL)]
DTO["ProductListRow / OrderListRow
(read-model)"]
UI --> QS --> R --> DB
DB --> R --> DTO --> QS --> UI
Hier ist DTO kein „Transportbeutel für Daten“. Es ist ein Vertrag „Tabellenzeile“, kein „Domänenobjekt zum Leben und Entwickeln“.
2. Start: Tabellenspalten und Sortierung
Man möchte am liebsten mit dem Refactoring in der IDE beginnen: Repository öffnen, Rückgabetyp ersetzen und stolz auf Build klicken. Aber wenn man das tut, bekommt man fast sicher entweder eine Projection von der Größe einer Halbentität, oder drei verschiedene Varianten desselben DTOs, oder ein ewiges „Lassen Sie uns noch ein paar Felder hinzufügen, falls nötig“. Daher ist der erste Schritt nicht Code, sondern Klarheit: Welche Spalten braucht die Liste heute wirklich?
Für die Produktliste vereinbaren wir (für das Kursbeispiel), dass wir id, sku, name benötigen. Das reicht, um das Prinzip zu sehen. Wenn Ihr Bildschirm im Projekt bereits Status oder Preis anzeigt – kein Problem, fügen Sie später hinzu, aber nur das, was tatsächlich angezeigt wird.
Für die Bestellliste vereinbaren wir, dass wir id, orderNumber, customerEmail, status, createdAt benötigen. Das zeigt bereits eine wichtige Sache: Ein Feld (customerEmail) liegt in einer anderen Entität, entweder machen wir explizit einen join, oder wir leben in einer Welt der „zufälligen Nachladungen“.
Das nächste Stück Disziplin – stabile Sortierung. Für Produkte ist es praktisch, zuerst nach name und dann nach id zu sortieren, damit gleiche Namen die Vorhersagbarkeit nicht stören. Für Bestellungen ist es oft praktisch, nach createdAt desc und dann nach id desc zu sortieren, damit Seite 2 nicht „umzieht“, wenn neue Bestellungen eintreffen.
Wenn man das in einem Satz merken möchte: Zuerst die Spalten und die Sortierreihenfolge festlegen, dann die Projection auswählen und erst dann die Abfrage schreiben. Sonst baut man die API rückwärts: „Hier ist die Entität, und Sie holen sich das Nötige irgendwie heraus.“
Um nicht jede Liste von Grund auf neu zusammenzustellen, ist es praktisch, einen kurzen Workflow zu haben:
- Spalten und Default-Sort festlegen.
- Form des Read-Modells wählen: interface-based projection – wenn die Liste einfach und ein-Tabellen ist; class-based DTO oder explizites @Query – wenn Joins, Feldumbenennungen nötig sind und man die Spaltenzusammensetzung kontrollieren möchte.
- Sicherstellen, dass das Repository sofort das Read-Modell zurückgibt, nicht die Entität.
- Pageable und stabile Sortierung über einen eindeutigen Tie-Breaker anwenden.
- SQL-Trace und count-Abfrage überprüfen, wenn Sie Page zurückgeben.
Dann durchlaufen wir diese Route einfach für Product und PurchaseOrder. Für beide Beispiele bleiben wir auf dem class-based Track: So sieht man sowohl die Form des Ergebnisses als auch die Joins selbst, was bei Bestellungen besonders nützlich ist.
3. Product: entity → ProductListRow
Auf Product ist diese Route fast langweilig – und das ist ihr Vorteil. Die Liste ist ein-Tabellen und flach, daher würde hier auch eine interface-based projection passen. Aber um nicht in zwei verschiedene Zweige auseinanderzulaufen, behalten wir class-based select new bei: Es zeigt die Form des Ergebnisses maximal explizit und kann später ohne Überraschungen auf PurchaseOrder übertragen werden.
Stellen wir uns vor, wir hatten so etwas im Repository:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Rückgabe einer managed-entity: innerhalb der Transaktion ein lebendiges Hibernate-Objekt
// mit allen "Vorteilen" des Persistence Context (dirty checking, zufällige Änderungen usw.)
Page<Product> findByStatus(ProductStatus status, Pageable pageable);
}
Auf den ersten Blick sieht alles schön aus. Aber das Problem ist genau, dass die Methode „zu universell“ ist. Sie gibt eine managed-entity zurück, und innerhalb von @Transactional wird diese Produktliste Teil des Persistence Context mit all seinen Gewohnheiten: Snapshots, dirty Checking, potenzielle akzidentelle Updates, und die Versuchung, Lazy-Beziehungen direkt im Rendering der Liste zu laden.
Read-Modell für die Listenzeile erstellen
Das Read-Modell ist hier bewusst einfach: derselbe ProductListRow mit nur id, sku und name. Diese Form reicht für eine Katalogzeile und trennt die Liste ehrlich von der Entität Product.
Projection-Methode zum Repository hinzufügen
Jetzt ersetzen wir die „universelle“ Entitätsmethode durch eine echte Read-Methode, die Page<ProductListRow> zurückgibt. Wir schreiben eine explizite JPQL-Abfrage mit Konstruktorausdruck, damit man mit eigenen Augen sieht, welche Felder ausgewählt werden.
import com.example.commerce.catalog.dto.ProductListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
where p.status = :status
""")
// Wichtig: Die Abfrage ist für die Form des Ergebnisses verantwortlich, die stabile Reihenfolge kommt aus dem Pageable.
Page<ProductListRow> findProductRowsByStatus(ProductStatus status, Pageable pageable);
}
Hier sind direkt mehrere Punkte wichtig.
Erstens – wir haben nur die benötigten Spalten ausgewählt. Hibernate wird Product nicht als managed-entity materialisieren, weil wir nicht p auswählen, sondern new ....
Zweitens – die Abfrage bleibt im gleichen class-based Stil, der bereits durch select new bekannt ist. Das ist also keine neue Baseline, sondern das gleiche Muster, nur auf die Produktliste angewendet.
Drittens – die Sortierung codieren wir nicht in @Query fest. Die Seitenordnung lebt im Pageable, damit die Default-Sort dort sichtbar ist, wo wir die Liste tatsächlich zusammenstellen.
Nutzung über den Query-Service (dünne Schicht)
Ein guter Stil im Projekt ist es, solche Methoden nicht als „nur ein weiteres Repository“ zu halten, sondern als Read-Use-Case auf der Ebene des query-Pakets. Dann kann der Write-Service des Katalogs entity-orientiert bleiben, der Read-Service hingegen projection-orientiert.
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Absichtserklärung: Dies ist ein Lesevorgang, ohne versehentliche Schreibvorgänge.
public Page<ProductListRow> listActiveProducts(int page, int size) {
Sort sort = Sort.by("name").ascending()
.and(Sort.by("id").ascending());
PageRequest pageable = PageRequest.of(page, size, sort);
return productRepository.findProductRowsByStatus(ProductStatus.ACTIVE, pageable);
}
Auf diese Weise bleibt die Default-Sort neben dem Read-Use-Case und das Repository beginnt nicht, einen festen order by für alle Fälle mitzunehmen.
SQL nach dem Übergang
Wenn Sie vor dem Refactoring Page<Product> gemacht haben, wählt Hibernate normalerweise alle Spalten der Tabelle product aus (und manchmal noch unerwartete sekundäre Abfragen, wenn jemand irgendwo in Lazy-Beziehungen greift).
Nach dem Übergang zur Projection wird SQL „flach“ und ehrlich: etwa so (vereinfacht):
-- Wir lesen genau die Spalten, die für die Listenzeile benötigt werden.
-- Die Sortierung kommt aus dem Pageable, nicht aus einem hartcodierten ORDER BY in der Abfrage.
select p.id, p.sku, p.name
from product p
where p.status = ?
order by p.name, p.id
limit ? offset ?;
Und Sie sehen direkt mit eigenen Augen: „Ja, ich lese die Tabelle, ja, nur drei Spalten, ja, nur das, was die Liste braucht.“ Daneben macht Spring Data das erwartete count. In diesem Moment wird die Liste vorhersagbar gelesen: nur benötigte Spalten, ein content-Query plus count, ohne unnötiges managed-Leben drumherum.
4. PurchaseOrder: Liste mit Kunden-E-Mail
Wenn die Produktliste ein „Aufwärmen“ war, dann ist die Bestellliste schon näher an der Realität. Hier landet man sehr leicht in der Situation „ich lese Bestellungen, und dann lese ich separat Kunden, und dann separat noch …“ Genau diese Liste erzeugt oft das klassische N+1: Wir zeigen 20 Bestellungen und bei jeder geben wir die Kunden-E-Mail aus – und schon sind es +20 Abfragen ohne böse Absicht, einfach weil der Code „normal aussieht“.
Wir machen ein Read-Modell, das sofort customerEmail enthält, also das, was die Tabelle wirklich braucht, und holen das mit einer einzigen Abfrage über einen expliziten join.
DTO/record für die Bestell-Listenzeile
Das Read-Modell ist hier etwas reichhaltiger: OrderListRow(id, orderNumber, customerEmail, status, createdAt). Wichtig ist nicht die Länge des Ergebnisses, sondern dass customerEmail als Teil der Listenzeile kommt, nicht als Anlass, später in o.getCustomer() zu gehen.
Repository: join, Pageable und countQuery
Wir schreiben die Query-Methode. Der neue Delta im Vergleich zu Product ist nicht die Projection selbst, sondern der explizite join auf Customer und der explizite countQuery für Page.
import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
@Query(
value = """
select new com.example.commerce.orders.dto.OrderListRow(
o.id, o.orderNumber, c.email, o.status, o.createdAt
)
from PurchaseOrder o
join o.customer c
""",
countQuery = """
select count(o)
from PurchaseOrder o
join o.customer c
"""
)
Page<OrderListRow> findOrderRows(Pageable pageable);
}
Hier ist countQuery nicht aus Ritual explizit gemacht. Bei Page gibt es zwei verschiedene Fragen: Welche Zeilen jetzt anzeigen und wie viele Zeilen es insgesamt gibt. Sobald es mehr joins und Bedingungen gibt, nimmt der explizite countQuery die zusätzliche Magie und macht diesen Vertrag transparenter.
Und genau hier sieht man, warum class-based DTO eine gute Wahl für eine solche Liste bleibt: join und Spaltenzusammensetzung sind direkt in der Abfrage festgelegt, nicht versteckt hinter „erst geben wir PurchaseOrder zurück, dann holen wir irgendwie die E-Mail“.
Read-only Service und Pageable
Der Service bleibt sehr dünn: Er setzt eine stabile Default-Sort und übergibt sie an das Repository.
import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public Page<OrderListRow> listOrders(int page, int size) {
Sort sort = Sort.by("createdAt").descending()
.and(Sort.by("id").descending());
PageRequest pageable = PageRequest.of(page, size, sort);
return purchaseOrderRepository.findOrderRows(pageable);
}
Auf diese Weise bleibt die Seitenordnung dort, wo sie für einen Listen-Use-Case sein sollte: im Pageable, und die Abfrage antwortet nur für die Ergebnisform und die Joins.
Projection statt JOIN FETCH in der Liste
Hier ist es nützlich, einen subtilen Punkt zu erkennen, der nach dem Tag der Fetch-Werkzeuge oft verwechselt wird. Bei der Bestellliste könnte man das Problem der Kunden-E-Mail mit einem JOIN FETCH lösen, also immer noch die managed PurchaseOrder laden und zusätzlich Customer. Das Ergebnis ist ein „lebendiger Graph“, wo ein flaches Read-Result nötig ist.
Projection löst die Aufgabe anders: Der join wird nur für die Spalte customerEmail benötigt, nicht zum Laden des Entitätsgraphen. Das ist also kein Konkurrent zu Fetch-Werkzeugen, sondern oft der richtigere Ansatz auf der Ebene der Wahl des Read-Modells.
5. Read-Modell in Services und Transaktionen
Wenn Sie zum ersten Mal eine Liste auf Projection umstellen, entsteht ein typischer innerer Dialog des Entwicklers: „Und wie soll ich jetzt das Produkt bearbeiten, wenn die Liste kein Product zurückgibt?“ Genau das ist der Moment, in dem wir verstehen: Liste und Bearbeitung sind unterschiedliche Use Cases. Die Liste gibt eine Tabellenzeile zurück. Die Bearbeitung gibt eine Entität zurück oder lädt eine Entität innerhalb eines Write-Szenarios.
In unserem Projekt kann man das sehr einfach halten: Write-Services bleiben entity-orientiert, Query-Services werden projection-orientiert. Im Paket catalog.service leben Methoden zum Erstellen und Aktualisieren von Produkten, die nach dem Muster find + mutate arbeiten, Zustände verwalten und auf managed-Semantik setzen. Im Paket catalog.query lebt das Lesen der Liste als Page<ProductListRow>. Analog für Bestellungen.
Mit der Transaktion wird es auch einfacher, nicht schwieriger. Das Ergebnis einer Projection ist nicht managed. Es wird nicht im Persistence Context gespeichert. Es muss nicht in einer offenen Session „leben“. Das bedeutet, dass Sie weniger von Transaktionsgrenzen abhängig sind und ein geringeres Risiko haben, eine LazyInitializationException zu fangen, weil später jemand in Assoziationen greift. ProductListRow hat keine Assoziationen – maximal greifen Sie auf row.name() zu, und das ist einfach ein String, kein SQL-Trigger.
Schließlich eine wichtige Regel „Schießen Sie sich nicht ins eigene Bein, selbst wenn das Bein schön ist“: Projection sollte nicht zum Schreiben verwendet werden. Versuchen Sie nicht, die Liste zu „speichern“ oder den Bestellstatus über ein DTO zu „aktualisieren“. Ein Write-Szenario muss ein Write-Szenario bleiben: managed-Entität laden (oder Referenz holen), ändern, abschließen. Projection ist ein Read-Only-Vertrag. Ihre Stärke liegt gerade darin, dass sie nicht vorgibt, alles auf einmal zu können.
6. Überprüfung mit SQL-Trace
Es gibt eine gefährliche Illusion: „Ich habe List<Product> durch List<ProductListRow> ersetzt, also habe ich es schneller gemacht.“ In der Welt von Hibernate funktioniert das nicht. Wir haben bereits das wichtigste Werkzeug des Kurses – SQL-Trace und Statistiken. Und ein gutes Refactoring von Listen muss mit den Augen überprüft werden: Welche Abfragen gingen an die DB, wie viele, wie breit sind sie.
Vor dem Refactoring sah die Bestellliste sehr leicht so aus: eine Abfrage auf Bestellungen, und dann ein Haufen Abfragen auf den Kunden (wenn irgendwo die E-Mail gelesen wird). Im SQL-Trace sieht man das meist als gleiche select ... from customer where id = ?, die sich viele Male wiederholen. Und ja, der Code kann harmlos aussehen, besonders wenn es sich „nur um Logging“ oder „nur um Mapping in DTOs“ handelt.
Nach dem Übergang zur Projection für Bestellungen wird das Bild langweilig – das ist ein Kompliment. Sie sehen eine Abfrage, die sofort einen join auf den Kunden enthält, und keine weiteren Überraschungen bezüglich der E-Mail. Für Page kommt ein count hinzu, aber das ist erwartet und kontrollierbar. Und das Schönste: Die Breite des select wird kleiner. In die Liste gehen genau die Spalten, die Sie ausgewählt haben. Nicht 20 Bestellfelder, nicht der ganze Kunde, sondern nur die benötigten 4–5 Werte.
Wenn das Profil sql-trace aktiviert ist, ist es sehr nützlich, sich an ein kurzes Ritual nach solchen Änderungen zu gewöhnen: Führen Sie das Listenszenario aus, sehen Sie sich das SQL an, vergewissern Sie sich, dass es eine (oder zwei für Page) Abfragen ist, vergewissern Sie sich, dass es keine sekundären select auf Lazy-Beziehungen gibt, vergewissern Sie sich, dass das select nichts Überflüssiges mitbringt. Das dauert eine Minute, spart aber stundenlange Rätselraten „warum es in der Produktion langsam ist, obwohl auf meinem Laptop alles fliegt“.
7. Typische Fehler
Fehler Nr. 1: „Kosmetisches“ Refactoring ohne Änderung des Rückgabetyps.
Wenn Sie Listen auf Projection umstellen, ist der häufigste Fehler ein „kosmetisches“ Refactoring: Die Methode in findProductRows... umbenennen, aber Page<Product> beibehalten. Äußerlich sieht es schöner aus, aber im Kern geben Sie immer noch eine managed-entity mit all ihren Nebeneffekten zurück. Überprüfen Sie daher nicht den Namen, sondern den Rückgabetyp und die Form des select.
Fehler Nr. 2: „Universelle“ Projection für alle Fälle.
Ein weiterer verbreiteter Fehler ist der Versuch, eine „universelle“ Projection zu machen, die für die Liste, die Karte, die Bearbeitung und „für alle Fälle“ noch ein paar Felder aus verwandten Entitäten enthält. Normalerweise wächst eine solche Projection schnell zu einer Halb-Entität an, und Sie landen wieder beim gleichen Problem: zu breiter Vertrag und zu viele Daten bei jedem Lesen. Die Disziplin hier ist einfach: Ein Use Case – ein Projection-Vertrag.
Fehler Nr. 3: Vertauschte Argumentreihenfolge in select new ....
Auch technische Details gehen oft schief. Zum Beispiel bei class-based projection kann man leicht die Argumentreihenfolge in select new ... und im Konstruktor (oder Canonical-Konstruktor eines Records) vertauschen. Der Code kompiliert, die Anwendung startet, und dann taucht plötzlich sku im Feld name auf. Das ist ein unangenehmer Fehler, weil er nicht immer mit einer Exception einhergeht – er macht die Daten einfach leise falsch. Behandeln Sie das mit Achtsamkeit und der Gewohnheit, select und Konstruktor nebeneinander zu halten und von links nach rechts zu lesen.
Fehler Nr. 4: Instabile Sortierung in pageable-Listen.
Ein separates Problem ist die instabile Sortierung in pageable-Listen. Sie sortieren Produkte nur nach name, fügen kein id hinzu, und irgendwann blättert der Benutzer durch die Seiten und sieht „springende“ Zeilen. Das ist kein Hibernate-Bug, sondern Ihre Unterlassung: Bei gleichem name ist die Reihenfolge nicht definiert. Daher braucht man in Listen fast immer einen eindeutigen Tie-Breaker, meistens id.
Fehler Nr. 5: „Optimieren von count“ ohne zu verstehen, dass Page zwei Abfragen bedeutet.
Schließlich sind viele überrascht, dass Page zwei Abfragen macht, und beginnen, „count zu optimieren“, ohne zu verstehen, dass das Problem woanders liegt. In einem echten Projekt kann count tatsächlich teuer werden, aber zuerst ist etwas anderes wichtiger: zu verstehen, dass ein pageable-Szenario zwei Abfragen sind, und diese im SQL-Trace lesen zu können. Wenn Sie das verstehen, hören Sie auf, Pagination mystisch zu fürchten, und beginnen, sie bewusst zu gestalten.
GO TO FULL VERSION