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, ob id null ist oder nicht. Wenn du id manuell vor dem Aufruf von save() setzt, wird Spring dies als Update behandeln und findById() + 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);
}
  • findByStatus generiert WHERE status = ?.
  • findByCustomerEmailAndStatus kombiniert zwei Bedingungen mit AND.
  • findFirstByCustomerEmailOrderByCreatedAtDesc ruft 1 Datensatz ab und sortiert absteigend nach createdAt — nützlich, um die neueste Bestellung eines Kunden zu finden.
  • countByStatus gibt 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 @SqlResultSetMapping hinzufü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 @Modifying gehen 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 alte Order-Instanz im Speicher hält, sind die Daten im Speicher nicht synchronisiert — benötige clearAutomatically = true auf @Modifying oder rufe selbst entityManager.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.

spring-bootspring-data-jpajavadatabaseorm