1. Vertrag der Repository-Methoden
Selbst wenn die Wahl zwischen derived, JPQL, projection und native keine Magie mehr ist, kann das Repository dennoch mit Code lügen. Die häufigste Verwirrung liegt hier nicht mehr im Abfragetext, sondern in den grundlegenden Verben: wann der Code eine Entität lesen wollte, wann eine Referenz nehmen, wann einfach fragen „existiert es?“, und wann zählen.
Ehrlich gesagt haben viele Anfänger mit Repositories ungefähr dieses Bild: „Na ja, da gibt es save, da gibt es findById, und noch ein paar Methoden … Hauptsache, es kompiliert.“ Ich verstehe. Genauso scheint es in der Kindheit, dass die Verkehrsregeln für Erwachsene sind und nicht für einen selbst. Aber sobald Sie auf die echte Straße fahren (sprich: ein echtes Projekt), stellt sich heraus, dass der Vertrag der Methoden wichtiger ist als ihr Name.
Spring Data JPA gibt Ihnen einen grundlegenden Repository-Vertrag (CrudRepository, JpaRepository) – ihm ist die Semantik im Voraus zugewiesen. Daneben leben die vertrauten derived-Muster wie countBy..., wo der Vertrag aus dem Methodennamen zusammengesetzt wird. Diese Dinge sind leicht zu verwechseln, und man beginnt, Methoden nach Klang und nicht nach Bedeutung auszuwählen.
Lassen Sie uns eine „Karte der Absichten“ erstellen (eine sehr nützliche Sache, wenn Sie Code nach einem halben Jahr lesen und sich zu erinnern versuchen, wer das geschrieben hat – Sie oder Ihre Katze). Behalten wir nur die Präzisierung im Kopf: findById, getReferenceById und existsById sind Teil des grundlegenden Repository-Vertrags, und countBy... ist ein derived-Muster für eine aggregierte Abfrage.
| Methode | Was Sie dem Code tatsächlich sagen wollen | Ergebnistyp | Wann SQL normalerweise ausgeführt wird | Wenn Datensatz nicht vorhanden |
|---|---|---|---|---|
| findById(id) | „Ich brauche eine Entität, aber sie kann fehlen“ | Optional<T> | Normalerweise sofort SELECT | Optional.empty() |
| getReferenceById(id) | „Ich brauche eine Referenz auf eine Entität anhand der id (ich garantiere nicht, dass die Daten schon geladen sind)“ | T (oft Proxy) | Kann bis zum Zugriff auf Felder aufgeschoben werden | Fehler kann später auftreten |
| existsById(id) | „Ich brauche eine Ja/Nein-Antwort zur Existenz“ | boolean | SELECT EXISTS/SELECT 1/COUNT (abhängig vom Provider) | false |
| countBy...(...) | „Ich brauche einen Zähler, nicht die Daten“ | long | SELECT COUNT(...) | 0 |
Und hier kommt der wichtige Gedanke des Tages: Ein gutes Repository ist kein „Methodenfriedhof“, sondern eine Sprache der Absichten. Wenn Sie die Methode richtig gewählt haben, liest sich Ihr Service wie ein menschlicher Satz. Wenn Sie „was gerade zur Hand war“ gewählt haben, wird der Service zur Verschlüsselung.
Um es noch besser zu verankern, hier ein Mini-Auswahldiagramm (nicht über Abfragewerkzeuge im Allgemeinen, sondern genau über diese vier Methoden):
flowchart TD
%% Auswahl der Methode nach Bedeutung: Lesen / Referenz / Prüfung / Statistik
A["Ergebnis nach ID benötigt"] --> B{"Wird 'ja/nein' benötigt?"}
B -->|"Ja"| E["existsById(id)"]
B -->|"Nein"| C{"Wird eine Entität benötigt (Felder lesen)?"}
C -->|"Ja"| D["findById(id) -> Optional"]
C -->|"Nein"| F["getReferenceById(id) (Referenzobjekt)"]
2. findById: Lesen und Optional
Wenn Sie zum ersten Mal Optional in Java-Code sehen, gibt es zwei häufige Szenarien. Das erste – die Person freut sich: „Oh, null wird es nicht mehr geben!“. Das zweite – die Person ist traurig: „Oh, jetzt muss ich eine Zeile mehr schreiben…“. In der Data-Layer-Welt ist Optional keine Verzierung, sondern ein direktes Signal: Das Fehlen eines Datensatzes ist ein normales Ergebnis der Abfrage.
findById ist die Methode, die Sie wählen, wenn Sie wirklich eine Entität lesen müssen (und wahrscheinlich mit ihren Feldern arbeiten), aber auf die Situation vorbereitet sind, dass es keine Zeile mit dieser ID gibt. Zum Beispiel könnte ein Produkt gelöscht worden sein, oder die Anfrage kam mit einer falschen ID, oder Sie haben im Test vergessen, die erforderlichen Daten zu erstellen (Klassiker).
Mini-Beispiel für unser Projekt: Wir lesen ein Produkt und wandeln das Fehlen in einen verständlichen Domänenfehler um (vorerst ohne REST-Fehler und Controller – wir sind immer noch in der Data-Layer-Welt).
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
// Repository – unser Zugriff auf Daten (Vertrag Spring Data JPA)
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProductOrThrow(long productId) {
// findById -> Optional: Fehlen eines Datensatzes wird hier als normales Ergebnis betrachtet
return productRepository.findById(productId)
// Explizit wandeln wir "keine Zeile in DB" in einen Domänen-/Servicefehler um
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));
}
}
Beachten Sie: Hier gibt es keine „Magie“ – wir sagen direkt, dass das Fehlen eines Produkts in eine Ausnahme umgewandelt wird. Und das ist viel ehrlicher, als null zurückzugeben und zu hoffen, dass der Code irgendwo weiter „selbst darauf kommt“.
Oft wird findById sowohl in Read-Szenarien (Produktkarte abrufen) als auch in Write-Szenarien (Preis ändern) verwendet. Auch wenn Sie Aktualisierungen vorerst über save durchführen (und nicht über Dirty Checking – das ist ein separates großes Thema später), bleibt findById dennoch Ihr wichtigster „Einstieg“ in die Arbeit mit einer Entität per ID.
Hier ein Beispiel „Preis eines Produkts ändern“ in einem lehrbuchartig geradlinigen Stil:
import java.math.BigDecimal;
public void changePrice(long productId, BigDecimal newPrice) {
// 1) Zuerst holen wir die Entität (oder stürzen mit einer verständlichen Fehlermeldung ab)
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));
// 2) Ändern die Daten im Objekt
product.setPrice(newPrice);
// 3) Im Lehrbeispiel speichern wir explizit (später besprechen wir Dirty Checking)
productRepository.save(product);
}
Ja, in der realen Welt werden wir später besprechen, warum nicht immer ein save nach jedem set... nötig ist (und warum ORM manchmal ein UPDATE ohne explizites save sendet). Aber selbst wenn Sie „fortgeschrittener“ werden, bleibt die Logik der Auswahl von findById dieselbe: „Ich möchte ein Objekt lesen und damit als Daten arbeiten“.
Ein weiterer Punkt, den man wissen sollte, ohne in die Tiefen abzutauchen: Wenn Sie innerhalb einer Transaktion zweimal findById für dieselbe ID aufrufen, macht Hibernate oft keine zwei SELECT-Befehle, weil es einen First-Level-Cache (Persistence Context) hat. Das ist gut, aber man sollte darauf keine Architektur aufbauen nach dem Motto „ich kann find beliebig oft aufrufen“ – wählen Sie Methoden nach Sinn, und die Optimierung innerhalb der Transaktion soll ein angenehmer Bonus sein, nicht Ihre Strategie.
3. getReferenceById: Referenz auf eine Entität
Der Name getReferenceById klingt, als ob Ihnen so etwas wie ein „Zeiger“ in C/C++ gegeben würde. Und dem Sinn nach ist das nahe: Sie erhalten ein Objekt, das eine Entität per ID repräsentiert, aber nicht vollständig hier und jetzt geladen sein muss. In Hibernate ist das normalerweise ein Proxy-Objekt, das die Daten aus der DB „nachladen“ kann, wenn Sie auf die Felder zugreifen.
Das Wichtigste: getReferenceById ist nützlich, wenn Sie nur die ID benötigen, um Entitäten miteinander zu verknüpfen, aber die Felder dieser Entität nicht benötigt werden.
Ein klassischer Anwendungsfall in unserem mini-shop ist die Erstellung eines OrderItem, das ein Product benötigt, wobei wir Preis und Namen des Produkts aber bereits separat erhalten haben (oder wir lesen das Produkt überhaupt nicht, sondern arbeiten mit vorab berechneten Daten). Dann kann man das Produkt über eine Referenz an die Bestellposition „binden“:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.ordering.entity.OrderItem;
public OrderItem makeItem(long productId, int qty) {
// Wir nehmen eine "Referenz" auf Product: das kann ein Proxy sein und nicht sofort einen SELECT ausführen
Product productRef = productRepository.getReferenceById(productId);
OrderItem item = new OrderItem();
// Es ist wichtig, die Entitäten zu verknüpfen, damit FK (product_id) korrekt geschrieben wird
item.setProduct(productRef);
item.setQuantity(qty);
return item;
}
Die Idee hier ist einfach: Wir benötigen einen FK auf product_id in order_item, und die Referenz hilft uns dabei, ohne obligatorisches Lesen aller Felder von Product. Dabei ist es wichtig zu bedenken: Wenn Sie später plötzlich entscheiden, productRef.getName() (oder einen anderen Nicht-ID-Getter) aufzurufen, kann die ORM zur DB gehen und einen SELECT ausführen.
Um den Unterschied „an den Fingern“ zu spüren, hier ein kleines Code-Illustrationsstück. Es erhebt keinen Anspruch auf hundertprozentig gleiches Verhalten unter allen Bedingungen, vermittelt aber das richtige Gefühl:
// Referenz auf eine Entität per ID (garantiert kein sofortiges Lesen aus der DB)
Product ref = productRepository.getReferenceById(10L);
System.out.println(ref.getId()); // 10 (normalerweise ohne SELECT, ID ist bereits bekannt)
System.out.println(ref.getName()); // hier kann ORM einen SELECT ausführen, um name zu ermitteln
Jetzt – eine wichtige Falle für Anfänger. getReferenceById ist kein „schnelleres findById“. Es ist sinngemäß anders. Der schmerzhafteste Fehler ist, findById durch es zu ersetzen, „weil es kürzer ist und cooler klingt“.
Wenn Sie eine Referenz auf einen nicht existierenden Datensatz erhalten, sehen Sie das Problem möglicherweise nicht sofort. Der Fehler tritt oft später auf, wenn Sie versuchen, ein Feld zu lesen, oder wenn die ORM die Änderungen mit der DB synchronisiert. Das Ergebnis ist ein Bug mit der Geschichte: „Alles war normal, und dann plötzlich bumm“.
Daher ist die Regel einfach: getReferenceById – für Verknüpfungen und Referenzen. findById – für Lesen und Arbeit mit Daten.
4. existsById: Existenzprüfung
Die Methode existsById ist wunderbar. Das Problem ist nur, dass sie oft als magischer Schutz gegen Fehler verwendet wird: „Erst prüfe ich exists, dann mache ich etwas Ernstes“. In der Praxis führt das oft zu zwei Abfragen statt einer, und manchmal auch zu einem falschen Sicherheitsgefühl.
Die Semantik von existsById ist sehr geradlinig: Sie stellen die Frage „Existiert ein Datensatz mit dieser ID?“. Und Sie erhalten einen boolean. Keine Entitäten, keine Graphen, keine „halbe Ladung“.
Hier ein Beispiel, wo das normal klingt. Angenommen, wir haben eine Operation „Produkt löschen“, und die Geschäftslogik möchte zuerst einen verständlichen Fehler „Produkt nicht vorhanden“ geben und dann löschen. Im Lehrcode (ohne komplexe Verträge und REST-Schicht) könnte das so aussehen:
public void deleteProduct(long productId) {
// Wichtig: existsById antwortet nur mit "ja/nein" und lädt keine Entität
if (!productRepository.existsById(productId)) {
// Geben eine explizite Fehlermeldung "nicht gefunden"
throw new IllegalArgumentException("Product not found: " + productId);
}
// Nach der Prüfung führen wir die Aktion aus (aber das ist die zweite DB-Abfrage)
productRepository.deleteById(productId);
}
Das liest sich: erst Prüfung, dann Aktion. Aber dieser Ansatz hat seinen Preis: zwei DB-Besuche. Manchmal ist das gerechtfertigt (z.B. wenn Sie wirklich zwischen „nicht gefunden“ und „gelöscht“ unterscheiden wollen), aber oft nicht.
Das Haupt-Antipattern ist die Verwendung von findById(...).isPresent() anstelle von existsById. Technisch funktioniert es, aber sinngemäß ist es „ich habe eine Entität geladen (oder zu laden versucht), um eine Ja/Nein-Frage zu beantworten“. Es ist ungefähr so, als würde man einen LKW bestellen, um ein Brötchen zu liefern: Es kommt im Laden an, aber es sieht seltsam aus.
// Technisch möglich, aber die Absicht ist schlechter: Sie lesen die Entität anscheinend für "ja/nein"
boolean exists = productRepository.findById(productId).isPresent();
Und das zweite Antipattern – die Kombination von existsById + findById in einem Szenario, wenn Sie letztendlich ohnehin die Entität benötigen. Das macht den Code nicht „zuverlässiger“, sondern wortreicher und teurer in Bezug auf Abfragen.
Wenn Sie eine Entität benötigen, gehen Sie direkt zu findById und wandeln Sie das Fehlen in einen Fehler um. Wenn Sie ein Ja/Nein brauchen, verwenden Sie existsById. Und bitte machen Sie aus exists kein Ritual „für alle Fälle“, sonst fängt Ihr Repository an, überflüssige SQL-Geräusche zu machen, wie ein alter Drucker, der Papier nicht mag.
5. countBy...: COUNT und Zähler
countBy... ist eine derived-Methode, aber sinngemäß ist sie näher an „aggregierten“ Abfragen: Sie fordern nicht die Daten, sondern eine Zahl. Und das ist eine sehr gesunde Gewohnheit: Wenn Sie einen Zähler brauchen, geben Sie einen Zähler zurück. Keine Liste. Nicht „Liste und dann size()“. Nicht „Laden wir alle Produkte und zählen dann“.
In unserem Projekt können Zähler nützlich sein, z.B. für ein „Mini-Dashboard“ oder für Prüfungen „Gibt es überhaupt aktive Produkte in der Kategorie?“ (nicht der beste Geschäftsfall, aber methodisch verständlich). Das Repository könnte so aussehen:
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// COUNT nach Status (Rückgabe einer Zahl, nicht einer Liste)
long countByStatus(ProductStatus status);
// COUNT nach Kategorie und Status: nützlich für Statistik/Validierungen
long countByCategoryIdAndStatus(Long categoryId, ProductStatus status);
}
Und der Service so:
public long countActiveProductsInCategory(long categoryId) {
// Rückgabe genau der Zahl: die Methode sagt explizit, dass wir eine Statistik benötigen
return productRepository.countByCategoryIdAndStatus(categoryId, ProductStatus.ACTIVE);
}
Was wichtig zu verstehen ist: countBy... lädt keine Entitäten. Es führt SELECT COUNT(...) aus und gibt eine Zahl zurück. Das ist normalerweise günstiger in Bezug auf Speicher und Netzwerk als das Laden einer Liste. Aber auch COUNT hat seinen Preis: Wenn die Tabelle riesig ist und die Bedingung nicht indiziert ist, kann die Zählung nicht sofort erfolgen. Wir gehen heute nicht in die Optimierung, aber das allgemeine Prinzip sollte man sich merken: Ein Zähler ist ein eigener Abfragetyp, und Sie sollten ihn bewusst anfordern, nicht „für alle Fälle“.
Als Bonus: Wenn Sie im Code countByStatus(ACTIVE) sehen, verstehen Sie sofort, dass der Service eine Statistik benötigt. Aber wenn Sie findByStatus(ACTIVE).size() sehen, verstehen Sie nichts, außer dass jemand nicht genug geschlafen hat.
6. Auswahlszenarien der Methode
Jetzt fassen wir alles in ein paar kleine, aber lebensnahe Szenarien aus unserem mini-shop zusammen. Es wird kein „ideales DDD“ geben, dafür das, was Sie wirklich in der Arbeit schreiben werden (und später schamvoll refaktorieren, wenn Sie erfahrener werden – das ist normal, wir alle haben das gemacht).
Erstes Szenario: „Produkt abrufen und seine Daten anzeigen/ändern“. Hier wird findById benötigt, weil wir wirklich mit den Feldern arbeiten und das Fehlen korrekt behandeln möchten.
public String getProductName(long productId) {
// Wir benötigen die Felder der Entität -> also lesen wir die Entität über findById
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));
return product.getName();
}
Zweites Szenario: „Bestellposition erstellen und einfach das Produkt per ID anbinden“. Hier ist getReferenceById angemessen, weil wir eine Referenz für den FK benötigen und nicht die Felder des Produkts lesen müssen.
public OrderItem addItem(CustomerOrder order, long productId, int qty) {
// Wir nehmen eine Referenz, um item -> product zu verknüpfen, ohne die Felder von Product lesen zu müssen
Product productRef = productRepository.getReferenceById(productId);
OrderItem item = new OrderItem();
// Verknüpfen die Bestellposition mit der Bestellung selbst
item.setOrder(order);
// Verknüpfen die Bestellposition mit dem Produkt (FK auf product_id)
item.setProduct(productRef);
item.setQuantity(qty);
return item;
}
Drittes Szenario: „Herausfinden, ob es das Produkt überhaupt gibt“, ohne seine Daten zu lesen. Zum Beispiel validieren Sie Eingabeparameter einer Operation, bei der das Produkt selbst nicht benötigt wird (das sei ein hypothetischer Lehrfall). Dann drückt existsById die Absicht besser aus.
public void assertProductExists(long productId) {
// Benötigt wird genau "ja/nein" -> existsById
boolean exists = productRepository.existsById(productId);
if (!exists) {
throw new IllegalArgumentException("Product not found: " + productId);
}
}
Und das vierte Szenario (kurz): „Anzahl der aktiven Produkte zählen“. Hier sagt countBy... direkt, dass wir eine Statistik benötigen.
public long countActiveProducts() {
// Benötigt wird Statistik -> COUNT
return productRepository.countByStatus(ProductStatus.ACTIVE);
}
Wenn Sie bemerkt haben, unterscheiden sich alle vier Beispiele nicht in der Syntax, sondern darin, was wir ausdrücken wollen. Das ist die „API-Semantik“. Ein gutes Repository ist, wenn die Methode so gewählt ist, dass der Leser keine unnötigen Fragen stellt. Ein schlechtes – wenn der Leser anfängt, SQL-Logs zu öffnen, um zu verstehen, was der Autor gemeint hat.
Sobald diese Verben an ihrem Platz sind, taucht die nächste typische Verwirrung in den Signaturen auf: Eine Aufgabe gibt plötzlich List zurück, eine andere Page, eine dritte ein nullable-Objekt, und die API wird wieder unehrlich. Dort ist es wichtig, sich nicht nur mit der Methode, sondern auch mit der Form der Antwort auseinanderzusetzen.
7. Häufige Fehler bei der Auswahl von Repository-Methoden
In diesem Thema fallen Fehler normalerweise nicht „schön“ in der ersten Zeile. Sie zeigen sich oft als merkwürdige überflüssige Abfragen, unerwartete Ausnahmen „irgendwo später“ oder Service-Code, der logisch aussieht, aber unnötige Arbeit leistet. Daher ist es nützlich, frühzeitig zu lernen, diese Harken an der Form des Codes zu erkennen, noch bevor Sie Probleme in den Logs sehen.
Fehler Nr. 1: findById durch getReferenceById überall ersetzen, „weil es so schneller ist“.
Solcher Code lebt oft bis zum ersten realen Fall, in dem „nicht gefunden“ sorgfältig behandelt werden muss. Die Referenz ist gut für die Verknüpfung von Entitäten per ID, aber sie sagt nicht „Datensatz existiert definitiv“ und verspricht nicht, dass die Daten bereits gelesen sind. Am Ende erhalten Sie Fehler, die nicht zum Zeitpunkt des Repository-Aufrufs auftreten, sondern später beim Zugriff auf ein Feld oder bei der Synchronisierung von Änderungen.
Fehler Nr. 2: Existenzprüfung über findById(...).isPresent() anstelle von existsById.
Technisch ist das korrekt, aber sinngemäß „tun Sie so“, als würden Sie eine Entität lesen, obwohl Sie nur ein Ja/Nein brauchen. Solcher Code liest sich schlechter und führt häufiger zu unnötigen Aktionen auf ORM-Ebene. Wenn die Frage „Existiert es?“ lautet, soll die Methode auch so klingen.
Fehler Nr. 3: Kombination von existsById(...) und sofort danach findById(...) in einem Use Case.
Das ist die häufigste Art, zwei Abfragen zu machen, wenn eine ausreicht. Wenn Sie ohnehin die Entität brauchen, rufen Sie findById auf und behandeln das Fehlen. Wenn Sie nur ein „ja/nein“ brauchen, rufen Sie existsById auf. Eine doppelte Prüfung bringt selten einen Vorteil und fast immer zusätzliches SQL.
Fehler Nr. 4: Erwarten, dass getReferenceById „die Existenz jetzt sofort prüft“.
Die Referenz ist kein „schneller find“. Sie kann sofort zur DB gehen oder nicht. Daher kann sie Ihnen nicht mitteilen, dass „ein solcher Datensatz nicht existiert“ genau zum Zeitpunkt des Aufrufs. Wenn es Ihnen wichtig ist, sofort zu verstehen, ob etwas existiert oder nicht, und zu reagieren, dann ist das der Bereich von findById oder existsById, nicht der Referenz.
Fehler Nr. 5: countBy... als Äquivalent zu „Liste lesen und .size() nehmen“ betrachten.
.size() auf einer Liste ist die Größe der bereits geladenen Collection. Um diese Collection zu erhalten, haben Sie zuerst alle Zeilen gelesen (und möglicherweise einen Teil der Verbindungen), Speicher, Zeit und Netzwerk verbraucht. countBy... macht eine Aggregatabfrage und gibt eine Zahl zurück. Wenn ein Zähler benötigt wird, fordern Sie den Zähler an, sonst veranstalten Sie einen Mini-DDOS auf die Datenbank.
GO TO FULL VERSION