JPA, Hibernate et Spring Data : repositories, requêtes et N+1
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,
existsByEmailest plus efficace quefindByEmail(...).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êteSELECT, puis unDELETEpar ligne. Pour une suppression en masse, une requête@ModifyingavecDELETEest 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=1000000revient à unfindAll(). Spring Data permet de fixer un maximum avecspring.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
- Active les logs SQL en local et compte les requêtes de chaque fonctionnalité.
- Requêtes dérivées pour les cas simples,
@Querydès que le nom devient long. - JPQL d'abord, SQL natif quand JPQL ne suffit plus.
- Jamais de
findAll()sur une table qui grossit : pagine, ou demande à la base uncount, une somme, un filtre. - Une taille de page maximale pour les API paginées.
- Des projections pour les écrans en lecture seule.
- Surveille les N+1 :
JOIN FETCH,@EntityGraphou projection, jamaisEAGER. - Pas de
JOIN FETCHsur une collection avec la pagination. @Modifyingpour les modifications et suppressions en masse, plutôt quedeleteByou une boucle desave().JdbcClientpour 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 ;@Queryen JPQL (entités et champs) ou en SQL natif pour le reste. findAll()charge toute la table : utilisePageableetPageouSlice.- Les projections ne lisent que les colonnes utiles, directement dans un DTO.
- Le piège des N+1 requêtes : une relation
LAZYchargée une par une dans une boucle. Il se voit dans les logs SQL, et se corrige avecJOIN FETCH,@EntityGraphou une projection. - Le SQL n'a pas disparu : il est généré pour toi. Regarde-le.