JPA, Hibernate et Spring Data : repositories, requêtes et N+1

Publié le · Mis à jour le

L'idée en une phrase

JPA permet de manipuler la base de données avec des objets Java plutôt qu'avec du SQL écrit à la main : tu demandes « le client 42 », tu reçois un objet Client, et c'est l'outil qui écrit et exécute le SQL. C'est très pratique, mais le SQL n'a pas disparu : il est généré pour toi. Bien utiliser JPA, c'est savoir quelles requêtes il exécute réellement, et garder la main quand il le faut.

Note : cet article complète la fiche sur les entités JPA (annotations, constructeurs, cycle de vie). Ici, on parle de tout le reste : qui fait quoi, les repositories, les requêtes, et les pièges de performance.

Une image pour comprendre : l'interprète

Imagine une réunion entre deux personnes qui ne parlent pas la même langue : ton code Java parle objets, la base de données parle tables et SQL.

  • JPA, ce sont les règles de traduction : un document officiel qui dit comment un objet correspond à une ligne, une relation à une clé étrangère. Ce n'est qu'un texte, une norme.
  • Hibernate, c'est l'interprète : il connaît ces règles et traduit réellement, dans les deux sens.
  • Spring Data JPA, c'est le secrétaire : pour les demandes courantes (« trouve-moi les clients actifs »), il rédige la demande à ta place, sans que tu aies à l'écrire.

Un bon interprète fait gagner un temps énorme. Mais si tu ne vérifies jamais ce qu'il dit, il peut faire cent allers-retours là où une seule phrase suffisait. C'est exactement le problème des « N+1 requêtes », plus bas.

Qui fait quoi

Ton code (services)
      │
Spring Data JPA   ← les repositories : findByEmail, save, findAll…
      │
JPA               ← la norme : @Entity, EntityManager, JPQL
      │
Hibernate         ← l'implémentation : génère et exécute le SQL
      │
JDBC              ← la couche de base de Java pour parler à une base
      │
PostgreSQL
Couche Ce que c'est Tu l'utilises…
Spring Data JPA une bibliothèque qui crée les repositories pour toi tous les jours
JPA (Jakarta Persistence) la norme : les annotations, le langage JPQL à travers les annotations et @Query
Hibernate l'implémentation de JPA utilisée par Spring Boot indirectement ; tu vois son nom dans les logs et les erreurs
JDBC l'accès bas niveau à la base rarement, sauf besoin particulier

Quand une erreur mentionne org.hibernate, c'est Hibernate qui parle. Quand elle mentionne org.springframework.data, c'est Spring Data.

Les repositories Spring Data

Ce qui est fourni sans rien écrire

public interface ClientRepository extends JpaRepository<Client, Long> {
}

Cette interface vide suffit. Spring Data crée l'implémentation au démarrage, avec déjà :

Méthode Rôle
findById(id) un Optional avec l'entité, ou vide
findAll() toutes les lignes de la table (attention, voir plus bas)
save(entite) insère ou met à jour
deleteById(id) supprime
existsById(id) vérifie l'existence
count() compte les lignes

Les requêtes dérivées du nom

Spring Data sait lire le nom d'une méthode et en déduire la requête :

public interface ClientRepository extends JpaRepository<Client, Long> {

    Optional<Client> findByEmail(String email);

    List<Client> findByStatutOrderByNomAsc(StatutClient statut);

    List<Client> findByVilleAndDateInscriptionAfter(String ville, LocalDate date);

    boolean existsByEmail(String email);

    long countByStatut(StatutClient statut);
}
Mot dans le nom Traduction
findBy, existsBy, countBy, deleteBy ce que fait la requête
And, Or combine des conditions
After, Before, GreaterThan, LessThan comparaisons
Containing, StartingWith, IgnoreCase recherche dans un texte
In dans une liste de valeurs
OrderBy...Asc / Desc tri

Le nom utilise les champs Java de l'entité (dateInscription), pas les colonnes de la base (date_inscription). Une faute de frappe dans le nom est détectée au démarrage : l'application refuse de démarrer, avec un message qui cite le champ introuvable.

Astuce : pour savoir si quelque chose existe, existsByEmail est plus efficace que findByEmail(...).isPresent() : la base n'a pas à renvoyer la ligne entière.

Quand le nom devient trop long : @Query

List<Commande> findByClientVilleAndStatutInAndDateCreationBetweenOrderByDateCreationDesc(...); // ❌ illisible

Au-delà de deux ou trois conditions, écris la requête toi-même.

Écrire ses requêtes : JPQL avec @Query

@Query("""
        SELECT c FROM Commande c
        WHERE c.client.ville = :ville
          AND c.statut IN :statuts
          AND c.dateCreation >= :depuis
        ORDER BY c.dateCreation DESC
        """)
List<Commande> chercher(String ville, List<Statut> statuts, LocalDate depuis);

C'est du JPQL, le langage de requête de JPA. Il ressemble beaucoup au SQL, avec une différence importante :

JPQL SQL
on parle de… entités et champs Java tables et colonnes
exemple FROM Commande c WHERE c.dateCreation … FROM commande c WHERE c.date_creation …
relations on suit les champs : c.client.ville il faut écrire la jointure

Les :ville, :statuts sont des paramètres : Spring les remplit avec les arguments de la méthode, du même nom. Jamais de valeur concaténée dans la requête (voir l'injection SQL dans la fiche sur les String).

Le SQL natif

Quand une requête utilise une fonction propre à PostgreSQL, ou devient vraiment complexe, on peut écrire du SQL directement :

@Query(value = """
        SELECT * FROM produit
        WHERE to_tsvector('french', description) @@ plainto_tsquery('french', :recherche)
        """, nativeQuery = true)
List<Produit> rechercherTexte(String recherche);
JPQL SQL natif (nativeQuery = true)
✅ vérifié au démarrage ❌ une erreur n'apparaît qu'à l'exécution
✅ indépendant de la base ❌ lié à PostgreSQL (ou à la base utilisée)
❌ limité aux fonctions de JPA ✅ toutes les possibilités de la base

Commence par JPQL, et passe au SQL natif quand il te bloque.

Modifier en masse

Pour modifier ou supprimer beaucoup de lignes, ou faire un calcul directement en base (comme le stock dans la fiche sur la concurrence), on ajoute @Modifying :

@Modifying
@Query("UPDATE Commande c SET c.statut = :nouveau WHERE c.statut = :ancien AND c.dateCreation < :avant")
int archiver(Statut ancien, Statut nouveau, LocalDate avant); // le nombre de lignes modifiées

Une seule requête UPDATE, quel que soit le nombre de lignes. La méthode qui l'appelle doit être @Transactional.

Note : méfie-toi de deleteByStatut(...) sur un grand nombre de lignes. Spring Data charge chaque entité puis la supprime une par une : une requête SELECT, puis un DELETE par ligne. Pour une suppression en masse, une requête @Modifying avec DELETE est bien plus efficace.

Paginer : ne jamais tout charger

findAll() charge toute la table en mémoire. Avec 50 lignes en développement, aucun problème ; avec 3 millions en production, l'application tombe (voir la fiche sur la mémoire en Java).

Spring Data gère la pagination avec un simple paramètre Pageable :

Page<Commande> findByStatut(Statut statut, Pageable pageable);
Page<Commande> page = commandeRepository.findByStatut(
        Statut.PAYEE,
        PageRequest.of(0, 20, Sort.by("dateCreation").descending())); // page 0, 20 éléments, triés

page.getContent();        // les 20 commandes
page.getTotalElements();  // le nombre total de commandes payées
page.getTotalPages();     // le nombre de pages

Dans un contrôleur, Spring sait même construire le Pageable à partir de l'adresse : GET /commandes?page=2&size=20&sort=dateCreation,desc.

Type renvoyé Contenu Coût
Page<T> les éléments et le nombre total deux requêtes : les éléments, puis un COUNT
Slice<T> les éléments, et s'il y a une page suivante une seule requête
List<T> (avec Pageable) les éléments seulement une seule requête

Pour un défilement infini (« charger plus »), Slice suffit et évite le COUNT, parfois coûteux sur une grosse table.

Astuce : limite toujours la taille de page qu'un client de l'API peut demander. Sinon, un simple ?size=1000000 revient à un findAll(). Spring Data permet de fixer un maximum avec spring.data.web.pageable.max-page-size.

Ne lire que ce dont on a besoin : les projections

Pour afficher une liste de commandes avec le nom du client et le montant, inutile de charger les entités complètes, leurs relations et leurs vingt colonnes. Une projection ne lit que les colonnes utiles, directement dans un DTO :

public record CommandeResume(Long id, String nomClient, BigDecimal montant) {}
@Query("""
        SELECT new fr.exemple.boutique.commande.CommandeResume(c.id, c.client.nom, c.montant)
        FROM Commande c
        WHERE c.statut = :statut
        """)
List<CommandeResume> resumes(Statut statut);

Le new suivi du nom complet de la classe indique à JPA de construire directement l'objet. Avantages :

  • moins de données lues et transférées ;
  • pas d'entité gérée : rien à surveiller pour Hibernate, aucune modification accidentelle possible (voir le cycle de vie dans la fiche sur les entités) ;
  • pas de LazyInitializationException : tout ce qui est utile est déjà dans le DTO.

Pour les écrans en lecture seule (listes, tableaux de bord, exports), c'est souvent la meilleure solution.

Voir le SQL généré

C'est le réflexe le plus important avec JPA. Tant que tu ne vois pas les requêtes, tu ne sais pas ce que fait réellement ton code.

# application-local.properties
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
Réglage Affiche
org.hibernate.SQL=DEBUG chaque requête SQL exécutée
org.hibernate.orm.jdbc.bind=TRACE les valeurs des paramètres de chaque requête

Active-les en local quand tu développes une fonctionnalité qui touche à la base, et compte les requêtes. Le résultat surprend souvent.

Note : tu verras aussi spring.jpa.show-sql=true. Il affiche le SQL directement dans la console, sans passer par les logs. Préfère les deux réglages ci-dessus, plus complets. Et ne les active jamais en production : ils écrivent énormément, et les valeurs des paramètres peuvent contenir des données personnelles.

Le piège n°1 : les « N+1 requêtes »

Le problème

@Transactional(readOnly = true)
public List<CommandeDto> lister() {
    return commandeRepository.findAll().stream()
            .map(c -> new CommandeDto(c.getId(), c.getClient().getNom())) // ⚠️
            .toList();
}

Le code a l'air innocent. Dans les logs SQL :

SELECT * FROM commande;                    -- 1 requête : les 100 commandes
SELECT * FROM client WHERE id = 12;        -- puis, pour chaque commande,
SELECT * FROM client WHERE id = 7;         -- une requête pour charger son client…
SELECT * FROM client WHERE id = 31;
-- … 100 requêtes de plus

1 requête pour les commandes, puis N requêtes (une par commande) pour les clients : d'où le nom « N+1 ». Avec 100 commandes, 101 requêtes. Avec 10 000, la page met des secondes à s'afficher.

La cause : la relation client est en chargement LAZY (ce qui est le bon réglage, voir la fiche sur les entités). Le client n'est chargé qu'au moment où on y accède, pour chaque commande, l'une après l'autre.

Ce problème est invisible en développement avec 5 lignes, et apparaît en production avec 10 000. D'où l'intérêt de regarder les logs SQL.

Les solutions

1. JOIN FETCH : charger la relation dans la même requête

@Query("SELECT c FROM Commande c JOIN FETCH c.client")
List<Commande> findAllAvecClient();

Une seule requête, avec une jointure : les commandes et leurs clients arrivent ensemble.

2. @EntityGraph : la même chose, sans écrire la requête

@EntityGraph(attributePaths = "client")
List<Commande> findByStatut(Statut statut);

3. Une projection, si c'est pour de l'affichage

@Query("SELECT new fr.exemple.boutique.commande.CommandeResume(c.id, c.client.nom, c.montant) FROM Commande c")
List<CommandeResume> resumes();

c.client.nom dans une requête JPQL produit une jointure : une seule requête, et seulement les colonnes utiles.

Solution Quand
JOIN FETCH tu as besoin des entités complètes, avec une relation précise
@EntityGraph idem, sur une requête dérivée du nom
projection tu ne fais que lire pour afficher : souvent le meilleur choix

Ce qui n'est pas une solution : EAGER

@ManyToOne(fetch = FetchType.EAGER) // ❌
private Client client;

Mettre la relation en chargement systématique semble régler le problème… mais le déplace : chaque lecture d'une commande, partout dans l'application, charge alors son client, même quand on n'en a pas besoin. Et dans beaucoup de cas, Hibernate le fait toujours avec une requête par commande. Garde LAZY, et charge explicitement ce dont tu as besoin, là où tu en as besoin.

Attention : JOIN FETCH sur une liste avec la pagination

@Query("SELECT c FROM Client c JOIN FETCH c.commandes")
Page<Client> findAllAvecCommandes(Pageable pageable); // ⚠️

Charger une collection (commandes) avec JOIN FETCH multiplie les lignes : un client avec 10 commandes donne 10 lignes. La base ne peut plus paginer correctement, et Hibernate charge alors tout en mémoire pour paginer lui-même, avec un avertissement dans les logs (HHH90003004). C'est un findAll() déguisé.

Pour paginer une liste de clients avec leurs commandes, pagine d'abord les clients, puis charge leurs commandes dans une deuxième requête (WHERE c.client.id IN :ids).

Quand JPA n'est pas le bon outil

JPA est fait pour manipuler des objets métier : lire une commande, la modifier, l'enregistrer. Pour d'autres besoins, une approche plus directe est souvent plus simple :

Besoin Outil plus adapté
un rapport avec des agrégats complexes (GROUP BY, fonctions de fenêtrage) SQL natif, ou JdbcClient
un import de centaines de milliers de lignes JdbcClient / JdbcTemplate, en lot
une requête de recherche très spécifique à PostgreSQL SQL natif
List<StatistiqueVente> stats = jdbcClient.sql("""
        SELECT date_trunc('month', date_creation) AS mois, SUM(montant) AS total
        FROM commande
        GROUP BY 1
        ORDER BY 1
        """)
        .query(StatistiqueVente.class)
        .list();

JdbcClient est fourni par Spring : il exécute du SQL et remplit directement des objets, sans entité. Mélanger JPA et JdbcClient dans un même projet est tout à fait normal : chaque outil là où il est le plus efficace.

Les bonnes habitudes

  1. Active les logs SQL en local et compte les requêtes de chaque fonctionnalité.
  2. Requêtes dérivées pour les cas simples, @Query dès que le nom devient long.
  3. JPQL d'abord, SQL natif quand JPQL ne suffit plus.
  4. Jamais de findAll() sur une table qui grossit : pagine, ou demande à la base un count, une somme, un filtre.
  5. Une taille de page maximale pour les API paginées.
  6. Des projections pour les écrans en lecture seule.
  7. Surveille les N+1 : JOIN FETCH, @EntityGraph ou projection, jamais EAGER.
  8. Pas de JOIN FETCH sur une collection avec la pagination.
  9. @Modifying pour les modifications et suppressions en masse, plutôt que deleteBy ou une boucle de save().
  10. JdbcClient pour les rapports et les traitements en masse : JPA n'est pas obligatoire partout.

À retenir

  • JPA est la norme, Hibernate l'implémentation qui génère le SQL, Spring Data JPA crée les repositories pour toi.
  • Les requêtes dérivées (findByEmail) couvrent les cas simples ; @Query en JPQL (entités et champs) ou en SQL natif pour le reste.
  • findAll() charge toute la table : utilise Pageable et Page ou Slice.
  • Les projections ne lisent que les colonnes utiles, directement dans un DTO.
  • Le piège des N+1 requêtes : une relation LAZY chargée une par une dans une boucle. Il se voit dans les logs SQL, et se corrige avec JOIN FETCH, @EntityGraph ou une projection.
  • Le SQL n'a pas disparu : il est généré pour toi. Regarde-le.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note