1. Kombinatorik der Filter
Fast jedes Projekt lebt so: Zuerst haben wir einen „Warenkatalog“ – Liste, Sortierung, Paginierung. Dann kommt die Bitte: „Filter nach Status hinzufügen“. Danach: „auch nach Kategorie“ und dann „Mindest- und Höchstpreis“. Und plötzlich machen Sie eine Admin‑Suche, bei der die Hälfte der Parameter fehlen kann. Das ist normal: Das Geschäft kennt selten im Voraus die vollständige Liste der Filter, weiß aber hervorragend, sie am Freitagabend auszudenken.
In unserem mini‑shop ist das sehr realistisch. Wir haben ein Product mit sku, name, status, price, createdAt, einer Beziehung zu Category und einer Beziehung zu StockItem. Für das Admin‑Panel (oder für einen internen Service) ist es naheliegend, Produkte nach einer Kombination von Bedingungen zu suchen: Teil des Namens oder SKU, Status, Kategorie, Preisbereich, „nur die auf Lager sind“, und manchmal auch „nach einem Datum erstellt“. Und hier beginnen drei klassische Wege … und zwei davon verwandeln sich recht schnell in „aua“.
Derived Queries und optionale Felder
Derived Queries sind wie ein gutes Messer in der Küche: Solange Sie Brot und Gurken schneiden, sind Sie glücklich. Aber wenn Sie damit eine Wohnung renovieren wollen, sieht das Messer plötzlich verdächtig klein aus und Sie zu optimistisch.
Wenn die Filter fest sind, liest sich die Repository‑Methode hervorragend: „Finde Produkte mit Status X“ oder „Finde Produkte in Kategorie Y“. Das Problem beginnt, wenn dasselbe Suchszenario einen Satz optionaler Bedingungen zulässt. Sie können nicht im Voraus sagen, ob der Statusfilter, der Kategoriefilter oder der Preisbereich verwendet wird. Jede Anfrage kann eine andere Kombination haben.
Schauen Sie sich dieses Stück Repository an. Es ist noch erträglich – aber deutet bereits an, dass es weh tun wird:
import java.math.BigDecimal;
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> {
// Einfacher derived query: ein Filter – eine verständliche Methode.
Page<Product> findByStatus(ProductStatus status, Pageable pageable);
// Sobald es mehrere Filter gibt, beginnt der Methodenname aufzublähen.
// Und das ist noch die „leichte“ Variante: ohne Textsuche, ohne inStockOnly, ohne Daten.
Page<Product> findByStatusAndCategory_IdAndPriceBetween(
ProductStatus status,
Long categoryId,
BigDecimal min,
BigDecimal max,
Pageable pageable
);
}
Solange wir uns im Rahmen von „obligatorische Parameter sind im Voraus bekannt“ bewegen, ist alles gut. Aber stellen Sie sich nun vor, dass status und categoryId optional sind. Dann taucht die Frage auf: Welche Methode soll aufgerufen werden, wenn status vorhanden ist, categoryId aber nicht? Und wenn categoryId vorhanden ist, status aber nicht? Wenn beide vorhanden sind, aber der Preis fehlt? Wenn nur der Mindestpreis ohne Höchstpreis angegeben ist?
Hier entsteht der Effekt, den ich gerne als „kombinatorische Explosion des Repositorys“ bezeichne. Jeder neue optionale Filter fügt nicht eine Methode hinzu – er multipliziert die Anzahl der Kombinationen. Sie haben noch nicht fertig findByStatusAndCategory_IdAndPriceBetween geschrieben, da bringt das Geschäft schon das Flag inStockOnly, und Ihre Hand zuckt zu einer Methode wie:
findByStatusAndCategory_IdAndPriceBetweenAndStockItem_AvailableQuantityGreaterThanAndCreatedAtAfter(...)
Wenn Sie jetzt das leichte Verlangen verspüren, in den Wald zu gehen und ein analoger Entwickler auf Papier zu werden – das ist eine normale Reaktion.
Wichtig zu verstehen: Derived Queries sind nicht „schlecht“. Sie müssen nur nicht die universelle Lösung sein. Ihre Stärke liegt in einfachen und klaren Abfragen, die sich nicht durch das Vorhandensein/Fehlen von Parametern ändern.
2. Langer @Query mit null-Bedingungen
Wenn die derived‑Methoden anschwellen, sucht das Gehirn nach Rettung. Und oft sieht sie so aus: „Machen wir eine Methode search(...) und schreiben innerhalb von @Query für jedes Feld (:param is null or ...)“. Von außen wirkt das wie ein Sieg über den Methodenzoo: eine Methode – und keine findByStatusAndCategoryAnd... über die gesamte Bildschirmbreite.
Ungefähr so:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
@Query("""
select p from Product p
where (:status is null or p.status = :status) -- falls Status nicht übergeben, Filter deaktiviert
and (:categoryId is null or p.category.id = :categoryId) -- falls Kategorie nicht übergeben, Filter deaktiviert
""")
Page<Product> search(ProductStatus status, Long categoryId, Pageable pageable);
Die Vorteile liegen auf der Hand. Sie haben eine Methode, die „irgendwie“ alles kann, und Parameter können als null übergeben werden, dann wird die Bedingung nicht angewendet. Das kann durchaus eine brauchbare Zwischenlösung sein.
Aber die Nachteile lassen nicht lange auf sich warten. Erstens: Mit zunehmender Anzahl von Bedingungen wird Ihr @Query zu einer langen Zeile (oder einem langen text block), in der die Logik der Optionalität innerhalb des Abfragetexts verteilt ist. Zweitens: Jede geschäftliche Änderung – z. B. „die Textsuche soll nicht nur nach name, sondern auch nach sku suchen“ – zwingt Sie, in dieses Textgebilde einzusteigen und es zu ändern. Drittens: Eine solche Abfrage lässt sich nur schwer in wiederverwendbare Teile zerlegen: Die Statusbedingung möchte man an einer Stelle verwenden, die Kategoriebedingung an einer anderen – und Sie bekommen alles zusammengeklebt.
Und es gibt noch einen psychologischen Nachteil: Nach einem Monat werden Sie diese Abfrage lesen und denken: „Wer hat das geschrieben?“ Spoiler: Sie selbst, aber in einem Zustand des Glaubens an eine helle Zukunft.
3. Suchkontrakt: filter object
Wenn eine Abfrage dynamisch wird, ist das Problem eigentlich zweifach. Der erste Teil: Wie baut man ein where aus einer Menge optionaler Felder? Der zweite Teil: Wie stellt man sicher, dass die API des Repositorys und der Services nicht zu einem Chaos aus Parametern und Methoden wird? Und hier kommt der erwachsene Ingenieursgedanke: Wir brauchen einen Suchkontrakt, der die Eingabe stabilisiert.
Anstatt im Code 6–8 Parameter herumzuschleppen (status, categoryId, minPrice, maxPrice, inStockOnly, createdFrom, text), erstellen wir ein einziges Objekt, das nur die Suchbedingungen speichert. In Java 25 geht das besonders angenehm mit einem record: kompakt, lesbar und verführt uns nicht dazu, Geschäftslogik hineinzuschreiben.
Hier ist ein Beispiel für einen solchen Filter für die Admin‑Produktsuche:
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record ProductAdminFilter(
// Textsuche (z. B. Teil des Namens oder der SKU) – kann null sein, wenn „ohne Text“ gesucht wird.
String text,
// Produktstatus – null bedeutet „nicht nach Status filtern“.
ProductStatus status,
// Kategorie‑ID – null bedeutet „Kategorie egal“.
Long categoryId,
// Preisuntergrenze – null bedeutet „keine Mindestpreisbegrenzung“.
BigDecimal minPrice,
// Preisobergrenze – null bedeutet „keine Höchstpreisbegrenzung“.
BigDecimal maxPrice,
// true – nur die, die auf Lager sind; false/null – diese Einschränkung nicht anwenden.
Boolean inStockOnly,
// Datum „erstellt nach“ – null bedeutet „nicht nach Erstellungsdatum filtern“.
LocalDateTime createdFrom
) {}
Beachten Sie die wichtige Disziplin: Das ist kein Antwort‑DTO, kein „Bildschirmmodell“, kein „universelles Objekt für die gesamte Anwendung“. Es ist ein konkretes Objekt für einen konkreten Use Case: Admin‑Produktsuche. Es muss nicht für alle Szenarien perfekt sein – und genau deshalb wird es gut sein. Universelle Objekte sind normalerweise nur in einer Sache universell: in der Anzahl der Probleme.
Aus architektonischer Sicht bringt das sofort mehrere Vorteile. Die Methodensignaturen hören auf aufzublähen, und Sie hören auf, fünf null für einen einzigen tatsächlichen Wert zu übergeben. Wenn das Geschäft einen neuen Filter hinzufügt, fügen Sie ein Feld im record und eine Regel zur Anbindung dieses Feldes an die Abfrage hinzu – anstatt einen weiteren derived‑Methodenzweig zu gebären oder den gigantischen @Query zu „verbessern“.
Und noch eine wichtige Sache: Das Filterobjekt fixiert im Code, was normalerweise nur „mündlich“ im Kopf lebt. Sie diskutieren nicht mehr: „na ja, die Produktsuche, wenn Status vorhanden ist, filtern wir, wenn nicht, dann nicht“. Sie haben buchstäblich ein Objekt, das sagt: „Das sind die Suchfelder; sie können null sein; das ist in Ordnung.“
4. Filter, Paginierung und Ergebnis
Wenn wir eine dynamische Suche machen, besteht die Versuchung, alles mit einer einzigen Struktur zu regeln: „Das Filterobjekt soll auch sort, page, size speichern und gleichzeitig die Liste der zurückzugebenden Felder“. In diesem Moment haben wir die Chance, ein Monster zu bauen, das gleichzeitig Filter, Paginierungseinstellung, Zeiger auf eine Projection und vielleicht heimlich das Wetter in Ihrer Stadt steuert.
In der Realität ist es besser, die Verantwortung zu trennen. Suchbedingungen beantworten die Frage: „Welche Zeilen passen?“. Paginierung und Sortierung beantworten die Frage: „In welcher Reihenfolge und mit welchem Ausschnitt zurückgeben?“. Und die Ergebnisform beantwortet die Frage: „Die Entity vollständig oder eine leichte Projection zurückgeben?“
Mit Paginierung und Ergebniscontainern sind wir bereits in Berührung gekommen: Die Wahl des Rückgabetyps ist eine eigene technische Entscheidung. Hier reicht es, eines festzuhalten: Das Filterobjekt sollte nicht alles auf sich laden. Es sollte ein einfacher Container für Bedingungen bleiben.
Daher sieht es im Code meist so aus: Der Filter kommt als ein Objekt, und Pageable als separater Parameter. Das ergibt eine lesbare Service‑Signatur und vermischt nicht „was zu suchen“ mit „wie zurückzugeben“.
Und das ist eine wichtige Grenze: Nach außen ist es bequemer, genau das Filterobjekt und Pageable zu exportieren, nicht den Mechanismus, mit dem später das WHERE zusammengebaut wird.
Ein Beispiel, wie angenehm ein Service‑Kontrakt aussieht, wenn man nicht sieben Parameter durchschleppt:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ProductAdminQueryService {
// Der Vertrag „was zu suchen“ (filter) ist getrennt von „wie zurückzugeben“ (pageable).
Page<Product> search(ProductAdminFilter filter, Pageable pageable);
}
Vorerst reicht uns der reine Kontrakt: sauber, kurz und ohne „rate mal, was der dritte BigDecimal in den Parametern bedeutet“.
5. Vergleichstabelle der Ansätze
Um den Gedanken zu festigen, lohnt sich ein kurzer Vergleich der drei Ansätze, die wir heute tatsächlich besprochen haben. Das ist nicht „die Wahrheit für alle Zeiten“, sondern eine praktische Karte: Was passiert mit der Wartbarkeit des Codes, wenn Bedingungen optional werden.
| Ansatz | Wie es aussieht | Was gut ist | Was beginnt zu schmerzen |
|---|---|---|---|
| Derived Queries für jede Kombination | viele findBy...-Methoden | sehr schneller Start, bei einfachen Bedingungen gut lesbar | Anzahl der Methoden wächst kombinatorisch, Namen werden aufgebläht, Wartung wird zur Handarbeit |
| Ein @Query mit (:x is null or ...) | eine Methode search(...) | ein Eingang, weniger Methoden | Abfrage wird lang, Bedingungen verklebt, Wiederverwendung von Logikteilen schwierig, schwer ohne Fehler erweiterbar |
| Filterobjekt als Vertrag | ein ProductAdminFilter + separater Pageable | stabiles API, einfacher erweiterbar, weniger null-Parameter in der Signatur | Filterobjekt erstellt selbst keine Abfrage: Es wird ein Mechanismus zur Bedingungssammlung benötigt (darauf gehen wir später ein) |
Das Wichtigste in dieser Tabelle ist, dass das Filterobjekt keine „neue Technologie“ ist. Es ist einfach eine Disziplin bei der Gestaltung des Eingangs eines Use Cases. Und es ist eine Disziplin auf dem Niveau: „Wir schreiben Code so, dass uns die Kollegen in zwei Monaten nicht hassen … einschließlich unseres zukünftigen Ichs.“
6. Typische Fehler bei der Suche
Fehler Nr. 1: Versuch, jede Kombination optionaler Filter mit einer separaten Repository‑Methode abzudecken.
Am Anfang scheint es: „Na gut, fügen wir noch ein paar Methoden hinzu.“ Aber sobald es mehr als drei optionale Felder gibt, gewinnt die Mathematik über den Wunsch, ruhig zu leben. Das Repository wächst, die Methoden werden unleserlich, und irgendwann ertappen Sie sich bei dem Gedanken, dass Sie mehr Zeit mit dem Erfinden von Namen verbringen als mit der eigentlichen Entwicklung.
Fehler Nr. 2: Direkte Übergabe von 6–8 Parametern an die Repository‑Methode und damit leben.
Das sieht normalerweise aus wie search(status, categoryId, min, max, inStockOnly, createdFrom, pageable). Formal funktioniert es, aber jeder Aufruf wird zu einer „Parade von null“: Die Hälfte der Argumente übergeben Sie leer. Nach einiger Zeit beginnen die Leute, die Parameterreihenfolge zu verwechseln, und Refactoring wird zu einem gefährlichen Sport. Ein Filterobjekt löst das fast ohne Magie: ein Argument statt sieben.
Fehler Nr. 3: Vermischung von Filterfeldern und Ergebnisfeldern in einer Struktur.
Manchmal möchte man ein Objekt erstellen, das „sowohl Filter als auch Antwort“ ist. Zum Beispiel ein Feld List<ProductCatalogRow> items in ProductAdminFilter hinzufügen. Das macht das Modell unklar: Ist es nun Eingabe oder Ausgabe? Der Filter soll die Suchbedingungen beschreiben. Das Ergebnis soll separat leben, in Page, Slice, Projection oder Entity – je nach Szenario.
Fehler Nr. 4: Überzeugung, dass „ein langer @Query“ automatisch sauberer ist als viele Methoden.
Eine Methode sieht oft ordentlicher im Interface aus, aber das bedeutet nicht, dass sie einfacher zu warten ist. Sobald die Abfrage komplexer wird und ein eigenes Leben beginnt (insbesondere mit Textsuche, Bereichen und Bedingungen über Beziehungen), wird das Lesen und Ändern schwer. Wir verbieten diesen Ansatz nicht, aber es ist wichtig, den Preis ehrlich zu sehen: Eine lange Abfragezeichenfolge ist auch eine Form von Komplexität, nur an anderer Stelle versteckt.
Fehler Nr. 5: Erstellung eines „universellen Filterobjekts für das gesamte Projekt“.
Eine sehr verlockende Idee: SearchFilter mit Feldern status, text, createdFrom, createdTo, sort, page, size, entityType, und dann „irgendwie klären wir das schon“. Normalerweise endet es damit, dass der Filter anschwillt, Felder enthält, die nicht zum konkreten Use Case gehören, und zu einem Müllcontainer wird. Halten Sie das Filterobjekt innerhalb der Grenzen des konkreten Szenarios: ProductAdminFilter für die Admin‑Produktsuche ist hervorragend; „universeller Filter für alles“ ist fast immer schlecht.
GO TO FULL VERSION