Spring Data JPA: Repository, Query Method und @Query
1. Übersicht
Spring Data JPA unterstützt dich dabei, die Datenzugriffs-Schicht zu schreiben, ohne dabei fast keinen Code implementieren zu müssen. Du deklarierst nur die Schnittstelle, Spring generiert automatisch SQL-Anweisungen auf Basis des Method-Namens oder der @Query-Annotation.
Dieser Artikel behandelt drei Hauptaspekte: wie man ein einfaches Repository erstellt, welche Benennungsregeln Query Methods befolgen damit Spring die WHERE-Bedingungen automatisch ableiten kann, und wann man @Query verwendet, wenn der Method-Name nicht aussagekräftig genug ist oder komplexe JPQL/Native-SQL-Anfragen nötig sind. Nach dem Lesen wirst du wissen, welcher Ansatz für jede praktische Situation am besten passt.
2. Projektsetup
Füge die Spring Data JPA-Abhängigkeit und den Datenbank-Treiber hinzu (beispielsweise H2 für die Demo):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Beispiel-Entity, die in diesem Artikel durchgehend verwendet wird:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerEmail;
private BigDecimal totalAmount;
private OrderStatus status;
private LocalDateTime createdAt;
// getters, setters
}
public enum OrderStatus {
PENDING, PAID, SHIPPED, CANCELLED
}
Order hat einfache Felder: Kunden-Email, Gesamtbetrag, Status, Erstellungszeit. Die Query-Beispiele unten drehen sich alle um diese Entity.
3. Grundlegendes Repository
Du musst nur JpaRepository erweitern, Spring Boot generiert automatisch die Bean-Implementierung beim Starten:
public interface OrderRepository extends JpaRepository<Order, Long> {
}
JpaRepository<Order, Long> gibt an, dass die verwaltete Entity Order ist und der Primärschlüssel vom Typ Long. Diese Schnittstelle hat bereits Methoden wie save(), findById(), findAll(), deleteById() — du musst keinen zusätzlichen Code schreiben.
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order createOrder(Order order) {
return orderRepository.save(order);
}
}
Der Aufruf von save() führt ein INSERT aus, wenn id null ist, und ein UPDATE, wenn id bereits vorhanden ist. Dies ist das Standardverhalten von SimpleJpaRepository — der Implementierung, die Spring Data automatisch generiert.
Hinweis:
save()unterscheidet nicht zwischen “neu erstellen” und “aktualisieren” basierend auf Geschäftslogik, sondern danach, obidnull ist oder nicht. Wenn duidmanuell vor dem Aufruf vonsave()setzt, wird Spring dies als Update behandeln undfindById()+ merge ausführen, was eine zusätzliche unnötige SELECT-Query verursacht.
4. Query Method: Naming zur Query-Generierung
4.1. Grundregeln
Spring Data JPA parst Method-Namen nach Mustern wie findBy, existsBy, countBy, deleteBy kombiniert mit Feldnamen und Bedingungsschlüsselwörtern:
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status);
List<Order> findByCustomerEmailAndStatus(String email, OrderStatus status);
Optional<Order> findFirstByCustomerEmailOrderByCreatedAtDesc(String email);
long countByStatus(OrderStatus status);
}
findByStatusgeneriertWHERE status = ?.findByCustomerEmailAndStatuskombiniert zwei Bedingungen mitAND.findFirstByCustomerEmailOrderByCreatedAtDescruft 1 Datensatz ab und sortiert absteigend nachcreatedAt— nützlich, um die neueste Bestellung eines Kunden zu finden.countByStatusgibt die Anzahl zurück, nicht die Liste.
4.2. Vergleichs- und Bereichsschlüsselwörter
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByTotalAmountGreaterThan(BigDecimal amount);
List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
List<Order> findByStatusIn(List<OrderStatus> statuses);
List<Order> findByCustomerEmailContainingIgnoreCase(String keyword);
}
GreaterThan, Between, In, ContainingIgnoreCase sind vordefinierte Schlüsselwörter, die Spring Data JPA unterstützt und direkt auf entsprechende SQL-Klauseln abbilden. ContainingIgnoreCase generiert LOWER(customer_email) LIKE LOWER('%keyword%') — praktisch für die Suche ohne Unterscheidung von Groß- und Kleinschreibung.
Test, um das Verhalten zu überprüfen:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersByStatus() {
Order order = new Order();
order.setStatus(OrderStatus.PAID);
order.setCustomerEmail("a@test.com");
order.setTotalAmount(BigDecimal.valueOf(100));
order.setCreatedAt(LocalDateTime.now());
orderRepository.save(order);
List<Order> result = orderRepository.findByStatus(OrderStatus.PAID);
assertThat(result)
.hasSize(1)
.first()
.extracting(Order::getCustomerEmail)
.isEqualTo("a@test.com");
}
}
@DataJpaTest lädt nur den JPA-bezogenen Context (Repository, Entity Manager) und ist schneller als @SpringBootTest. Standardmäßig verwendet es eine In-Memory-Datenbank und führt nach jedem Test einen Rollback durch.
4.3. Pagination und Sortierung
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
System.out.println(page.getTotalElements());
System.out.println(page.getContent());
Pageable ist ein spezieller Parameter — Spring Data erkennt ihn automatisch und behandelt ihn nicht als WHERE-Bedingung. Page<Order> gibt Metadaten zurück: Gesamtzahl der Elemente, Gesamtzahl der Seiten, Inhalt der aktuellen Seite.
4.4. Wenn der Method-Name verwirrend wird
// Schwer zu lesen, anfällig für Tippfehler bei Schlüsselwörtern
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
OrderStatus status, String keyword, LocalDateTime after);
Solch lange Method-Namen funktionieren zwar noch, sind aber schwer zu lesen und zu warten, und der Compiler kann dir nicht bei der Syntax-Überprüfung helfen — Spring Data erkennt Fehler nur beim Context-Start (fail-fast, aber immer noch zur Laufzeit). Dies ist der Punkt, an dem du zu @Query wechseln solltest.
5. @Query: Direkte Kontrolle der Befehle
5.1. Basis-JPQL
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :minAmount")
List<Order> findHighValueOrders(@Param("status") OrderStatus status,
@Param("minAmount") BigDecimal minAmount);
}
@Query schreibt JPQL — eine Entity-orientierte Abfragesprache, nicht reines SQL (verwende Order o, o.status statt Tabellen-/Spaltennamen). @Param bildet Parameternamen in der Abfrage auf Method-Parameter ab, was flexible Variablennamen ermöglicht, unabhängig von der Reihenfolge.
5.2. Native SQL wenn nötig
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query(value = "SELECT * FROM orders WHERE total_amount > :minAmount LIMIT :limit",
nativeQuery = true)
List<Order> findTopOrders(@Param("minAmount") BigDecimal minAmount,
@Param("limit") int limit);
}
nativeQuery = true erlaubt es dir, echtes SQL zu schreiben und datenbankspezifische Syntax wie LIMIT zu verwenden. Im Gegenzug verlierst du die Portabilität zwischen verschiedenen Datenbanken (beispielsweise unterstützt Oracle nicht LIMIT).
Hinweis: Native Queries geben Entities noch korrekt zurück, wenn die Spaltennamen mit der Abbildung übereinstimmen, aber wenn du beliebige DTOs herauswählst, musst du
@SqlResultSetMappinghinzufügen oder ein Projection-Interface verwenden — komplizierter als JPQL.
5.3. Update und Delete mit @Modifying
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus")
int updateStatusBulk(@Param("oldStatus") OrderStatus oldStatus,
@Param("newStatus") OrderStatus newStatus);
}
@Modifying teilt Spring Data mit, dass dies eine datenmodifizierende Anweisung (UPDATE/DELETE) ist, keine SELECT. Die Method gibt int zurück, die Anzahl der betroffenen Zeilen. Ohne @Modifying wird eine InvalidDataAccessApiUsageException geworfen, da JPA ein ResultSet zum Mapping erwartet.
Hinweis: Bulk-Updates mit
@Modifyinggehen direkt in die Datenbank, nicht durch den Entity-Lebenszyklus (triggern nicht@PreUpdate, aktualisieren nicht den Cache verwalteter Entities im Persistence Context). Wenn der Service eine alteOrder-Instanz im Speicher hält, sind die Daten im Speicher nicht synchronisiert — benötigeclearAutomatically = trueauf@Modifyingoder rufe selbstentityManager.clear()auf.
5.4. Projection: Nur benötigte Felder abrufen
Wenn du nicht die gesamte Entity benötigst, verwende ein Interface-Projection, um die zurückgegebenen Daten zu reduzieren:
public interface OrderSummary {
Long getId();
String getCustomerEmail();
BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id AS id, o.customerEmail AS customerEmail, o.totalAmount AS totalAmount " +
"FROM Order o WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);
}
Spring Data erstellt zur Laufzeit einen Proxy, der OrderSummary implementiert und bildet nur die 3 ausgewählten Felder ab. Dies vermeidet das Laden der gesamten Entity, wenn nur eine Zusammenfassung angezeigt werden soll — reduziert die Last sowohl für die Query als auch für den Speicher.
Test für @Query:
@DataJpaTest
class OrderRepositoryQueryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindHighValueOrders() {
Order cheap = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(50));
Order expensive = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(500));
orderRepository.saveAll(List.of(cheap, expensive));
List<Order> result = orderRepository.findHighValueOrders(
OrderStatus.PAID, BigDecimal.valueOf(100));
assertThat(result)
.hasSize(1)
.first()
.extracting(Order::getTotalAmount)
.isEqualTo(BigDecimal.valueOf(500));
}
private Order buildOrder(OrderStatus status, BigDecimal amount) {
Order order = new Order();
order.setStatus(status);
order.setTotalAmount(amount);
order.setCustomerEmail("test@test.com");
order.setCreatedAt(LocalDateTime.now());
return order;
}
}
Dieser Test bestätigt, dass die Bedingung totalAmount > minAmount korrekt funktioniert — nur die Bestellung mit 500 wird zurückgegeben, die Bestellung mit 50 wird ausgeschlossen.
6. Vergleich zwischen Query Method und @Query
| Kriterium | Query Method | @Query |
|---|---|---|
| Schreibgeschwindigkeit | Schnell für einfache Bedingungen | Manuelles Schreiben von JPQL/SQL erforderlich |
| Unterstützte Komplexität | Limitiert bei JOIN, Subquery | Flexibel, unterstützt alle JPQL/SQL |
| Lesbarkeit | Lange Namen bei vielen Bedingungen | Klar, Query-Logik getrennt |
| Fehlerprüfung | Fehler beim Context-Start | Fehler beim Context-Start (JPQL), Fehler zur Laufzeit (Native SQL) |
| Geeignet für | Basis-CRUD, 1-3 Bedingungen | Komplexe Queries, JOIN, Aggregate, Projection |
Auswahlprinzipien: Beginne mit Query Methods für einfache Fälle. Wenn der Method-Name mehr als 3-4 Bedingungen überschreitet oder JOIN/Subquery nötig ist, wechsle zu @Query für bessere Lesbarkeit und Kontrolle.
7. Häufige Fehler
@Param vergessen, wenn Named Parameters verwendet werden:
// Fehler: @Param fehlt, Spring weiß nicht, welcher Parameter :status ist
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);
Lösungsansatz: Füge @Param("status") zum Parameter hinzu, oder kompiliere mit dem Flag -parameters, damit Spring Parameternamen automatisch aus dem Bytecode extrahiert (Spring Boot aktiviert dieses Flag standardmäßig im Maven/Gradle-Plugin, aber es ist trotzdem ratsam, @Param explizit anzugeben, um die Abhängigkeit von der Build-Konfiguration zu vermeiden).
findAll() verwenden und dann mit Java Stream filtern, statt eine Query zu schreiben:
// Nicht empfohlen: lädt alle Daten aus der Datenbank in den Speicher und filtert dann
List<Order> paidOrders = orderRepository.findAll().stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.toList();
Dies lädt alle Daten aus der Datenbank in die Anwendung, verschwendet Bandbreite und Speicher. Schiebe Filter-Bedingungen immer in die Datenbank mit Query Methods oder @Query.
@Transactional vergessen, wenn @Modifying außerhalb einer Service-Transaktion aufgerufen wird:
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);
Das direkte Aufrufen dieser Method ohne umschließende Transaktion wirft TransactionRequiredException. Verpacke es immer in einer @Transactional Service-Methode, nicht direkt vom Controller aufrufen.
8. Fazit
Query Methods eignen sich für einfache Abfragen, halten den Code kurz und lesbar, wenn die Bedingungen nicht mehr als 2-3 Felder umfassen. Wenn die Logik komplexer wird — JOIN, Subquery, Bulk-Update oder benutzerdefinierte Projection erforderlich — ist @Query die klarere und wartbarere Wahl.
Allgemeine Prinzipien: Bevorzuge es, Filter-Logik in die Datenbank zu verschieben, statt sie in Java zu verarbeiten; schreibe immer Tests für jede wichtige Query; verwende Native SQL nur, wenn JPQL nicht ausreicht (beispielsweise für datenbankspezifische Funktionen). Mit diesen drei Techniken beherrschst du die meisten Datenabfrage-Anforderungen in praktischen Spring Boot-Anwendungen.