1. Szenario: Admin-Produktsuche
Einzelne hasStatus(...), priceGte(...) und textSearch(...) sind nur so lange nützlich, bis daraus eine echte Suche zusammengebaut wird. Die Admin-Oberfläche hat eine einzige Anfrage: Produkte schnell nach einer beliebigen Kombination optionaler Felder zu finden, ohne 30 Methoden in ProductRepository zu erzeugen.
Stellen wir uns einen typischen Admin-Bildschirm in unserem Projekt shop-data-jpa vor: „Produkte nach Text, Status, Kategorie, Preisbereich finden, nur die anzeigen, die auf Lager sind, und das alles seitenweise“. Der naive Ansatz wäre, für jede Kombination von Bedingungen eine Repository-Methode zu schreiben. Wir haben bereits gesehen, wozu das führt: Das Repository verwandelt sich in eine Enzyklopädie der Zaubersprüche.
Unser Ziel hier ist sehr praktisch: eine verständliche Pipeline „Filter → Spezifikation → Abfrage → Ergebnisseite“ zu erstellen.
Hier ist das Schema, das wir erreichen wollen (und im Kopf behalten sollten, wenn der Code etwas länger wird):
flowchart TD
A["ProductAdminFilter
(Bedingungsdaten)"] --> B["ProductSpecifications.byFilter(filter)
Spezifikation zusammenbauen"]
B --> C["Specification<Product>
endgültiges WHERE"]
C --> D["productRepository.findAll(spec, pageable)"]
D --> E["Page<Product>
Inhalt + Seitenmetadaten"]
Und eine kleine „Zuordnungstabelle“ (damit wir später nicht den Überblick verlieren, wer für was zuständig ist):
| Filterfeld | Was es in menschlicher Sprache bedeutet | Welche Spezifikation (Idee) |
|---|---|---|
| text | Suche nach sku oder name | textSearch(text) |
| status | nur ACTIVE / ARCHIVED | hasStatus(status) |
| categoryId | Produkte aus der Kategorie | inCategory(categoryId) |
| minPrice | Preis von… | priceGte(minPrice) |
| maxPrice | Preis bis… | priceLte(maxPrice) |
| inStockOnly | nur das, was auf Lager ist | inStock() |
| createdFrom | erstellt nicht vor dem Datum | createdAfter(createdFrom) |
2. ProductAdminFilter: Suchvertrag
Das Filterobjekt haben wir bereits: text, status, categoryId, minPrice, maxPrice, inStockOnly, createdFrom. Es speichert weiterhin nur die Suchbedingungen, während Pageable ein separater Parameter bleibt, da es nicht für WHERE, sondern für das Ergebnisformat zuständig ist.
Jetzt geht es nicht darum, die Filterfelder erneut zu diskutieren, sondern sie in eine Kette spec = spec.and(...) zu verwandeln, bei der jedes optionale Feld entweder seine Bedingung hinzufügt oder gar nicht an der Abfrage teilnimmt.
3. byFilter: Komposition durch and
Jetzt machen wir das, woran die Psyche eines Anfängers normalerweise zerbricht: Wir bauen den Filter so zusammen, dass er nicht wie eine Suppe aus if-Anweisungen aussieht. Gute Nachricht: eine Suppe wird es trotzdem geben, nur dass wir vorher ein ordentliches Rezept hineingelegt haben. Das Schlüsselprinzip: Kein Wert – keine Bedingung. Wir versuchen nicht, „SQL zu emulieren“, sondern fügen nach und nach Spezifikationen dort hinzu, wo der Filter tatsächlich aktiviert ist.
Damit das Repository überhaupt Spezifikationen ausführen kann, muss es JpaSpecificationExecutor<Product> erweitern. Das ersetzt JpaRepository nicht, sondern fügt die Möglichkeit hinzu, findAll(spec, pageable) zu verwenden.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import com.example.shopdatajpa.catalog.entity.Product;
// Das Repository bleibt ein normales JpaRepository,
// erhält aber zusätzlich die Unterstützung für Specification-Abfragen.
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
Jetzt erstellen wir eine Utility-Klasse zum Zusammenbau von Spezifikationen. Von der Projektarchitektur her passt sie gut in catalog.query, da es sich um reine Read-Side-Abfragen handelt.
import org.springframework.data.jpa.domain.Specification;
import com.example.shopdatajpa.catalog.entity.Product;
// Utility-Klasse: baut Spezifikationen für die Suche zusammen.
// Es werden keine Instanzen benötigt – nur static-Methoden.
public final class ProductSpecifications {
private ProductSpecifications() {
// Schutz vor versehentlicher Instanzerstellung
}
public static Specification<Product> byFilter(ProductAdminFilter filter) {
// Starten mit „nichts filtern“
Specification<Product> spec = Specification.unrestricted();
// Hier werden nach und nach die Bedingungen hinzugefügt:
// spec = spec.and(...)
return spec;
}
}
Der Sinn von Specification.unrestricted() ist einfach: Es ist ein neutrales „nichts filtern“. Das ist, als würde man ein Sandwich mit einem leeren Brot beginnen, nicht mit einem mysteriösen Objekt „könnte Brot sein, könnte kein Brot sein“.
Als nächstes kommt der Hauptteil: Wir fügen Bedingungen nur hinzu, wenn sie aktiv sind. Dabei ist es wichtig, sich ein Detail zu merken, das oft übersehen wird: spec.and(...) ändert nicht das Objekt spec „intern“. Es gibt eine neue Specification zurück. Deshalb machen wir immer eine Zuweisung spec = spec.and(...).
Hier ist ein typischer Teil für Text, Status und Kategorie:
// 1) Text: nur berücksichtigen, wenn nicht leer
if (filter.text() != null && !filter.text().isBlank()) {
// Wichtig: and(...) gibt eine neue Specification zurück
spec = spec.and(textSearch(filter.text()));
}
// 2) Status: falls gesetzt – filtern
if (filter.status() != null) {
spec = spec.and(hasStatus(filter.status()));
}
// 3) Kategorie: falls gesetzt – filtern
if (filter.categoryId() != null) {
spec = spec.and(inCategory(filter.categoryId()));
}
Warum ist das lesbar? Weil es hier keine Verzweigungen „wenn das eine, sonst das andere“ gibt. Alle Bedingungen sind unabhängig, und das spiegelt sich im Code wider. Wir versuchen nicht, im Voraus zu berechnen, „welche Abfrage genau benötigt wird“ – wir fügen einfach WHERE-Fragmente hinzu, wenn nötig.
Und jetzt eine wichtige Klarstellung zum Stil. Es ist sehr leicht, in einen riesigen byFilter zu verfallen, in dem alles direkt in einer Methode geschrieben wird. Das vermeiden wir so: byFilter kümmert sich nur um die „Orchestrierung“ der Bedingungen, und jede Bedingung ist in eine kleine Spezifikation wie hasStatus, priceGte, textSearch ausgelagert. In der letzten Vorlesung haben wir genau gelernt, solche kleinen Dinger zu schreiben – heute sollen sie sich auszahlen.
4. Bereiche: Preis und Datum
Bereiche sind die klassische Stelle, an der ein Anfänger eine „Monsterbedingung“ baut. Zum Beispiel versucht er, minPrice, maxPrice, Null-Prüfungen und noch drei Geschäftsüberlegungen in eine Methode zu stopfen. In der Praxis ist es viel einfacher und robuster, einen Bereich als zwei unabhängige Halbbedingungen zu betrachten: „nicht weniger“ und „nicht mehr“. Wenn nur eine Grenze gesetzt ist, funktioniert es trotzdem. Sind beide gesetzt, ergibt sich ein „zwischen“.
Beginnen wir mit der unteren und oberen Preisgrenze. Wichtig: Wir filtern nach BigDecimal, weil wir Geld (und ähnliche Werte) in der JPA-Welt genau so halten.
import java.math.BigDecimal;
import org.springframework.data.jpa.domain.Specification;
import com.example.shopdatajpa.catalog.entity.Product;
public static Specification<Product> priceLte(BigDecimal maxPrice) {
return (root, query, cb) ->
// price <= maxPrice
cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}
priceGte(minPrice) wird symmetrisch sein (wir haben eine solche Spezifikation in der letzten Vorlesung geschrieben). Jetzt createdFrom. Bei Daten ist es dasselbe: „nicht vor dem angegebenen Zeitpunkt“.
import java.time.LocalDateTime;
import org.springframework.data.jpa.domain.Specification;
import com.example.shopdatajpa.catalog.entity.Product;
public static Specification<Product> createdAfter(LocalDateTime from) {
return (root, query, cb) ->
// createdAt >= from
cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}
Und wir fügen diese Bedingungen nach den gleichen Regeln „Kein Wert – keine Bedingung“ in den Zusammenbau ein:
// Untere Preisgrenze
if (filter.minPrice() != null) {
spec = spec.and(priceGte(filter.minPrice()));
}
// Obere Preisgrenze
if (filter.maxPrice() != null) {
spec = spec.and(priceLte(filter.maxPrice()));
}
// Datum „erstellt nicht vor“
if (filter.createdFrom() != null) {
spec = spec.and(createdAfter(filter.createdFrom()));
}
Jetzt eine wichtige Nuance, die wir extra besprechen, weil an dieser Stelle oft „seltsame Ergebnisse“ auftauchen und der Wunsch entsteht, Hibernate zu beschuldigen. Wenn der Benutzer (oder Ihr aufrufender Code) einen Bereich „auf dem Kopf“ übergibt, z.B. minPrice = 1000, maxPrice = 10, dann werden die Spezifikationen ehrlich zusammengebaut, SQL wird ehrlich ausgeführt – und Sie erhalten ehrlich eine leere Seite. Das ist kein JPA-Fehler. Es fehlt die Eingabevalidierung.
Der Zusammenbau der Spezifikationen ist nicht für die Validierung zuständig (sonst wird er zum Filter, Validator und Kaffeesatzleser zugleich). Eine einfache Prüfung macht man besser im Service (sogar bevor die Spezifikation gebaut wird):
import java.math.BigDecimal;
public static void validate(ProductAdminFilter filter) {
// Validierung der Bereichsgrenzen: min <= max
BigDecimal min = filter.minPrice();
BigDecimal max = filter.maxPrice();
if (min != null && max != null && min.compareTo(max) > 0) {
// Das ist ein Eingabefehler, kein JPA/Hibernate-Problem
throw new IllegalArgumentException("minPrice must be <= maxPrice");
}
}
Beachten Sie: Für BigDecimal erfolgt der Vergleich über compareTo, nicht über > (und das ist ein guter Moment, um sich daran zu erinnern: Geld ist kein double, so sehr man auch „einfacher“ haben möchte).
5. Das Flag inStockOnly und Lagerbestände
Ein boolean-Flag in einem Filter ist eine trügerisch einfache Sache: Es scheint „eine Zeile“ zu sein. In der Praxis brechen genau boolean-Flags am häufigsten wegen null, wegen falscher Interpretation von false oder weil die Bedingung an eine verknüpfte Entität gekoppelt ist. Bei uns ist das genau der Fall: inStockOnly bedeutet „zeige Produkte, bei denen stockItem.availableQuantity > 0 ist“.
Zuerst – eine sorgfältige Prüfung des Flags im Zusammenbau. Der sicherste Stil für optionales Boolean:
// Der Filter wird nur bei explizitem true aktiviert
if (Boolean.TRUE.equals(filter.inStockOnly())) {
spec = spec.and(inStock());
}
Warum so und nicht if (filter.inStockOnly())? Weil filter.inStockOnly() null sein kann, und dann bekommen Sie eine NullPointerException. Und wenn Sie eine NPE im Filter haben, wird die Admin-Suche zur Attraktion.
Jetzt die Spezifikation selbst. In der einfachen Variante (und für OneToOne ist das in der Regel in Ordnung) kann man den Weg root.get("stockItem").get("availableQuantity") gehen:
import org.springframework.data.jpa.domain.Specification;
import com.example.shopdatajpa.catalog.entity.Product;
public static Specification<Product> inStock() {
return (root, query, cb) ->
// availableQuantity > 0
cb.greaterThan(
root.get("stockItem").get("availableQuantity"),
0
);
}
An dieser Stelle ist es nützlich, eine Feinheit zu besprechen. Wenn Sie über eine Verknüpfung navigieren, kann JPA einen Join erzeugen. Und wenn die verknüpfte Entität nicht existiert, wird der Filter strenger, als es auf den ersten Blick scheint: Ein Produkt ohne StockItem wird die Bedingung availableQuantity > 0 einfach nicht erfüllen.
Wenn es Ihnen wichtig ist, den Join explizit zu machen und seine Semantik genauer zu steuern, können Sie eine Variante mit LEFT JOIN schreiben. Aber der LEFT JOIN allein rettet hier noch keine Produkte ohne stockItem: Das Prädikat availableQuantity > 0 im WHERE schneidet sie trotzdem ab. Diese Variante ist nicht dadurch nützlich, dass sie magisch das Ergebnis ändert, sondern weil der Join explizit wird und sich leichter erweitern lässt.
Hier ein Beispiel für eine Variante mit LEFT JOIN (etwas ausführlicher, aber manchmal verständlicher, weil der Join explizit wird):
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import org.springframework.data.jpa.domain.Specification;
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.inventory.entity.StockItem;
public static Specification<Product> inStockLeftJoin() {
return (root, query, cb) -> {
// Explizit LEFT JOIN machen, um das Join-Verhalten zu kontrollieren
Join<Product, StockItem> stock = root.join("stockItem", JoinType.LEFT);
// Selbst mit LEFT JOIN wird diese Bedingung trotzdem nur Produkte behalten,
// bei denen availableQuantity > 0 ist.
return cb.greaterThan(stock.get("availableQuantity"), 0);
};
}
Hier kommt es nicht auf die Syntax an (die werden Sie sich sowieso nicht beim ersten Mal merken), sondern auf das mentale Modell: Ein Filter kann nicht nur auf Felder von Product schauen, sondern auch auf Felder verknüpfter Entitäten. Das ist einer der Gründe, warum Specification für die Admin-Suche sehr praktisch ist: Sie stoßen nicht an die Grenze „Ich kann nur nach Spalten dieser Tabelle filtern“.
6. Paginierung und findAll(spec, pageable)
Jetzt verbinden wir die beiden „Achsen“ der Suche: Bedingungen (Specification) und die Form der Ergebnisliste (Pageable). Es ist leicht, die Rollen zu verwechseln und anzufangen, Pageable in den Filter zu stecken oder umgekehrt. Aber hier ist alles recht strikt: Die Spezifikation beantwortet die Frage „welche Zeilen passen“, und Pageable beantwortet die Frage „welchen Ausschnitt des Ergebnisses und in welcher Reihenfolge wollen wir jetzt“.
Genau hier ist es sinnvoll, den Kanon des Tages festzuhalten: Von außen nimmt der Query-Service ProductAdminFilter und Pageable entgegen. Die Specification bleibt ein interner Mechanismus zum Bau des WHERE, keine neue öffentliche Sprache des Use Cases.
In der Implementierung des Query-Service sieht das normalerweise so aus:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public Page<Product> search(ProductAdminFilter filter, Pageable pageable) {
// 1) Eingabedaten des Use Cases prüfen
validate(filter);
// 2) WHERE-Teil der Abfrage aus dem Filterobjekt zusammenbauen
var spec = ProductSpecifications.byFilter(filter);
// 3) Abfrage seitenweise ausführen
return productRepository.findAll(spec, pageable);
}
Das ist die funktionierende Pipeline: validate(filter) → byFilter(filter) → productRepository.findAll(spec, pageable). Selbst wenn sich später der Ergebniscontainer oder die Projektion ändert, muss der externe Eingang des Use Cases nicht angefasst werden.
Jetzt einige wichtige Details zu Pageable (das sind Dinge, an denen selbst ein erfahrener Entwickler scheitert, wenn er nicht genug Schlaf bekommen hat):
Der Parameter page in PageRequest.of(page, size, sort) ist nullbasiert. Das heißt, die erste Seite ist 0. Wenn Sie „erste Seite“ schreiben und 1 setzen, werden Sie sich wundern, warum die ersten size Produkte „verschwunden“ sind. Das ist keine Magie, sondern ganz normale Mathematik. Und ja, das ist genau die Mathematik, von der wir versprochen haben, dass sie „beim Programmieren fast nie gebraucht wird“.
Hier ein Beispiel für die Erstellung eines Pageable für die Admin-Suche:
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
// Erste Seite ist 0 (nicht 1)
Pageable pageable = PageRequest.of(
0,
20,
// Sortierung nach dem Namen des Entitätsfelds, nicht nach dem Datenbankspaltennamen
Sort.by("createdAt").descending()
);
Beachten Sie: In Sort.by("createdAt") verwenden wir den Namen des Entitätsfelds, nicht den Spaltennamen in der Datenbank. Wenn Sie das Feld createdAt genannt haben, die Spalte aber created_at ist, schreiben Sie für die Sortierung trotzdem createdAt.
Und zum Schluss – ein kleines „lebendiges“ Beispiel, um zu spüren, dass Filter und Paginierung wirklich unabhängig sind. Wir können einen Filter setzen und einfach das Pageable ändern, ohne die Spezifikation anzurühren.
import java.math.BigDecimal;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
// Der Filter beantwortet die Frage "was soll gesucht werden"
ProductAdminFilter filter = new ProductAdminFilter(
"iphone",
ProductStatus.ACTIVE,
null,
new BigDecimal("100.00"),
new BigDecimal("2000.00"),
true,
null
);
// Pageable beantwortet die Frage "wie soll angezeigt werden": Seite/Größe/Sortierung
var pageable = PageRequest.of(0, 10, Sort.by("price").ascending());
Der Filter beschreibt „was gesucht werden soll“, PageRequest beschreibt „wie angezeigt werden soll“. Und wenn Sie diese Dinge getrennt halten, beginnt Ihr Code, sich wie eine technische Lösung anzufühlen, nicht wie eine Quest „Finde, wo bei uns die Suchparameter versteckt sind“.
7. Typische Fehler beim Zusammenbau eines Filters
Fehler Nr. 1: Der Filter beginnt, „sich selbst zu validieren“ und wird zur Geschäftslogik.
Man möchte in byFilter sofort schreiben: „wenn minPrice > maxPrice – vertauschen“ oder „wenn text zu kurz ist – ignorieren“. Das verwandelt den Spezifikationszusammenbau schnell in einen Ort, an dem die Hälfte der Produktregeln lebt. Besser ist es, den Filterzusammenbau so mechanisch wie möglich zu halten und die Eingabevalidierung getrennt (im Service) durchzuführen.
Fehler Nr. 2: Boolean wird mit if (filter.inStockOnly()) geprüft, und alles fällt mit NPE.
Optionale Flags erfordern fast immer Boolean.TRUE.equals(...). Sonst erhalten Sie bei null eine Exception. Das Ärgerlichste an diesem Fehler ist, dass er nicht zur Kompilierungszeit auftritt, sondern bei einem Benutzer, der einfach „das Häkchen nicht gesetzt hat“.
Fehler Nr. 3: spec.and(...) wird ohne Zuweisung aufgerufen, und der Filter „funktioniert nicht“.
Specification verhält sich wie ein immutable-Objekt: Die Methoden and/or geben ein neues Objekt zurück. Wenn Sie spec.and(hasStatus(...)); schreiben und spec = vergessen, werfen Sie höflich die Spezifikation in den Müll und werden dann lange in das SQL-Log starren mit dem Gedanken „warum filtert es nicht?“
Fehler Nr. 4: Der Preisbereich wird als eine riesige Bedingung geschrieben und ist später nicht erweiterbar.
Wenn minPrice und maxPrice als ein „Monster“ formuliert sind, fällt es schwer, die Fälle „nur min“, „nur max“, „beide“ und „keiner“ zu unterstützen. Es ist viel einfacher, zwei kleine Spezifikationen priceGte und priceLte zu halten und sie nach Bedarf hinzuzufügen.
Fehler Nr. 5: Verwechslung der Seitenzählung bei PageRequest.
PageRequest.of(0, 20) ist die erste Seite. Wenn Sie 1 übergeben, beginnen Sie mit der zweiten. Dieser Fehler ist besonders tückisch, wenn Sie eine UI anbinden, denn „es scheint zu funktionieren, aber irgendwelche Produkte fehlen“. Sie sind nicht verschwunden – Sie haben sie überblättert.
GO TO FULL VERSION