Spring Data JPA : Repository, Query Method et @Query

1. Aperçu

Spring Data JPA vous aide à écrire la couche d’accès aux données avec très peu de code. Vous déclarez juste une interface, Spring génère automatiquement les requêtes SQL basées sur le nom de la méthode ou l’annotation @Query.

Cet article couvre trois parties principales : comment créer un repository de base, les règles de nommage des query method pour que Spring déduit automatiquement les conditions WHERE, et comment utiliser @Query quand le nom de la méthode n’est pas assez expressif ou quand vous avez besoin de JPQL/SQL natif complexe. Après la lecture, vous saurez choisir la bonne approche pour chaque situation réelle.

2. Configuration du projet

Ajoutez la dépendance Spring Data JPA et le driver de base de données (par exemple H2 pour la démo) :

<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>

Entity exemple utilisée tout au long de l’article :

@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 a des champs simples : email client, montant total, statut, date de création. Les exemples de requête ci-dessous tournent tous autour de cette entity.

3. Repository de base

Il suffit d’étendre JpaRepository, Spring Boot crée automatiquement le bean implementation au démarrage :

public interface OrderRepository extends JpaRepository<Order, Long> {
}

JpaRepository<Order, Long> indique que l’entity gérée est Order et que le type de clé primaire est Long. Cette interface offre déjà des méthodes comme save(), findById(), findAll(), deleteById() — pas besoin d’écrire du code supplémentaire.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order createOrder(Order order) {
        return orderRepository.save(order);
    }
}

Appeler save() effectuera un INSERT si id est null, un UPDATE si id existe. C’est le comportement par défaut de SimpleJpaRepository — l’implémentation générée automatiquement par Spring Data.

Attention : save() ne distingue pas entre « créer » et « mettre à jour » selon la logique métier, mais selon que id est null ou non. Si vous définissez id vous-même avant d’appeler save(), Spring le considérera comme une mise à jour et exécutera findById() + merge, ce qui cause une requête SELECT supplémentaire inutile.

4. Query method : nommer pour générer des requêtes

4.1. Règles de base

Spring Data JPA analyse le nom de la méthode selon des modèles comme findBy, existsBy, countBy, deleteBy combinés avec des noms de champs et des mots-clés de conditions :

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 génère WHERE status = ?.
  • findByCustomerEmailAndStatus combine deux conditions avec AND.
  • findFirstByCustomerEmailOrderByCreatedAtDesc récupère 1 enregistrement, trié par ordre décroissant sur createdAt — utile pour trouver la commande la plus récente d’un client.
  • countByStatus retourne le nombre d’enregistrements, pas une liste.

4.2. Mots-clés de comparaison et de plage

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 sont des mots-clés supportés nativement par Spring Data JPA, qui mappent directement aux clauses SQL correspondantes. ContainingIgnoreCase génère LOWER(customer_email) LIKE LOWER('%keyword%') — pratique pour une recherche insensible à la casse.

Test de vérification du comportement :

@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 charge uniquement le contexte lié à JPA (repository, entity manager), s’exécute plus rapidement que @SpringBootTest. Par défaut, utilise une base de données en mémoire et effectue un rollback après chaque test.

4.3. Pagination et tri

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 est un paramètre spécial — Spring Data le reconnaît automatiquement et ne le traite pas comme une condition WHERE. Page<Order> retourne avec des métadonnées : nombre total d’éléments, nombre total de pages, contenu de la page actuelle.

4.4. Quand les noms de méthodes deviennent difficiles

// Difficile à lire, facile de se tromper sur les mots-clés
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
    OrderStatus status, String keyword, LocalDateTime after);

Les noms de méthode si longs fonctionnent toujours, mais sont difficiles à lire, difficiles à maintenir, et le compilateur ne vous aide pas à vérifier la syntaxe — Spring Data ne détecte les erreurs qu’au démarrage du contexte (fail-fast mais toujours du runtime). C’est le moment de passer à @Query.

5. @Query : contrôle direct des requêtes

5.1. JPQL de base

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 écrit en JPQL — un langage de requête orienté entité, pas du SQL pur (utilise Order o, o.status au lieu des noms de table/colonne). @Param mappe le nom du paramètre dans la requête au paramètre de la méthode, ce qui rend les noms de variables flexibles, indépendants de l’ordre.

5.2. SQL natif si nécessaire

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 permet d’écrire du vrai SQL, vous pouvez utiliser la syntaxe spécifique à la base de données comme LIMIT. En échange, vous perdez la portabilité entre différentes bases de données (par exemple, Oracle ne supporte pas LIMIT).

Attention : Les requêtes natives retournant une entity fonctionnent toujours si les noms de colonnes correspondent au mapping, mais si vous SELECT un DTO arbitraire, vous devez ajouter @SqlResultSetMapping ou utiliser une projection interface — plus complexe que JPQL.

5.3. Update et Delete avec @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 indique à Spring Data que c’est une instruction de modification de données (UPDATE/DELETE), pas un SELECT. La méthode retourne int, le nombre de lignes affectées. Sans @Modifying, cela lèverait une InvalidDataAccessApiUsageException car JPA attend un ResultSet à mapper.

Attention : Les mises à jour en masse avec @Modifying vont directement à la base de données, ne passent pas par le cycle de vie de l’entity (ne déclenchent pas @PreUpdate, ne mettent en cache à jour l’entity gérée dans le contexte de persistance). Si le service tient une ancienne instance Order en main, les données en mémoire ne seront pas synchronisées — vous avez besoin de clearAutomatically = true sur @Modifying ou appelez entityManager.clear() vous-même.

5.4. Projection : ne récupérer que les champs nécessaires

Quand vous n’avez pas besoin de l’entity complète, utilisez une projection d’interface pour réduire les données retournées :

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 crée un proxy implémentant OrderSummary à l’exécution, mapant seulement les 3 champs SELECT. Cette approche évite de charger l’entity complète quand vous avez juste besoin d’afficher un résumé — réduit la charge sur la requête et la mémoire.

Test pour @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;
    }
}

Ce test confirme que la condition totalAmount > minAmount fonctionne correctement — seule la commande de 500 est retournée, la commande de 50 est filtrée.

6. Comparaison entre query method et @Query

Critère Query method @Query
Vitesse d’écriture Rapide pour les conditions simples Nécessite d’écrire du JPQL/SQL manuellement
Complexité supportée Limitée avec JOIN, subquery Flexible, supporte tout JPQL/SQL
Lisibilité Nom long avec plusieurs conditions Clair, logique de requête séparée
Vérification d’erreurs Erreur au démarrage du contexte Erreur au démarrage (JPQL), à l’exécution (SQL natif)
Cas d’usage CRUD basique, 1-3 conditions Requêtes complexes, JOIN, agrégats, projection

Principe de sélection : commencez par query method pour les cas simples. Quand le nom de la méthode dépasse 3-4 conditions ou nécessite JOIN/subquery, passez à @Query pour plus de clarté et de contrôle.

7. Erreurs courantes

Oublier @Param lors de l’utilisation de named parameters :

// Erreur : manque @Param, Spring ne sait pas mapper :status à quel paramètre
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);

Correctif : ajoutez @Param("status") au paramètre, ou compilez avec le flag -parameters pour que Spring extraie les noms des paramètres du bytecode (Spring Boot active ce flag par défaut via le plugin Maven/Gradle, mais vous devriez toujours écrire @Param explicitement pour éviter de dépendre de la configuration de build).

Utiliser findAll() puis filtrer avec Java stream au lieu d’écrire une requête :

// À éviter : charger la table complète en mémoire puis filtrer
List<Order> paidOrders = orderRepository.findAll().stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .toList();

Cette approche charge toutes les données de la base vers l’application, gaspille la bande passante et la mémoire. Poussez toujours les conditions de filtrage vers la base de données via query method ou @Query.

Oublier @Transactional lors de l’appel de @Modifying en dehors d’une portée de service transactionnelle :

@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);

Appeler cette méthode directement sans transaction englobante lèverait TransactionRequiredException. Enveloppez toujours dans une méthode @Transactional au niveau du service, ne l’appelez pas directement depuis le contrôleur.

8. Conclusion

Les query method conviennent aux requêtes simples, gardent le code court et lisible quand les conditions ne dépassent pas 2-3 champs. Quand la logique devient plus complexe — nécessite JOIN, subquery, bulk update, ou projection personnalisée — @Query est le choix plus clair et plus maintenable.

Principe général : privilégiez de pousser la logique de filtrage vers la base de données plutôt que de la traiter en Java, écrivez toujours des tests pour chaque requête importante, et envisagez du SQL natif seulement quand JPQL n’est pas à la hauteur (par exemple si vous avez besoin de fonctions spécifiques à la base de données). Maîtriser ces trois techniques vous donne suffisamment d’outils pour gérer la plupart des problèmes d’accès aux données dans une application Spring Boot réelle.

spring-bootspring-data-jpajavadatabaseorm