1. Objectif de l'audit de persistance
Presque toujours, les ennuis avec Hibernate commencent au moment où « tout fonctionnait », mais soudainement c'est devenu lent, le SQL est étrange, ou l'équipe a peur de toucher à la couche de données. L'audit de persistance est une façon de regarder le code à l'avance et d'évaluer non pas « ça marche/ça ne marche pas », mais à quel point c'est prévisible et à quel point c'est contrôlable. Il ne s'agit pas de beauté ni de religion des annotations, mais d'observabilité technique.
Nous avons déjà vu des façons typiques de perdre le contrôle : entity leakage et giant graphs brouillent la forme des données, overscoped tx et chatty repository brouillent la limite de l'unité de travail, EAGER et OSIV brouillent l'endroit où le SQL est réellement exécuté. Maintenant, il faut un itinéraire de travail : comment parcourir un cas d'utilisation de manière à obtenir, à partir de ces odeurs, non pas une liste de plaintes, mais une première refactorisation cohérente.
Il vaut mieux commencer par un seul cas d'utilisation. Si ce passage est honnête, les mêmes questions permettront ensuite d'examiner toute la couche de persistance.
Pour simplifier, l'audit vérifie trois choses : quelle forme de données le cas d'utilisation renvoie, où se trouve la limite de la transaction, et quel SQL réel résulte de ce code. Il est important de ne pas commencer par « changeons fetch en EAGER » ou « ajoutons @BatchSize ». Nous commençons par des questions de niveau : « pourquoi renvoyons-nous une entité vers l'extérieur ? » et « pourquoi cette méthode maintient-elle une transaction tout en construisant un rapport texte ? ».
Mini-schéma : comment penser avec l'audit
flowchart TD
A["Prenons un cas d'utilisation"] --> B["Déterminer : lecture ou écriture ?"]
B --> C["Regarder la forme des données : entité ou modèle de lecture ?"]
C --> D["Regarder la limite de la transaction : où est @Transactional ?"]
D --> E["Regarder les repositories : y a-t-il des boucles et du chatter ?"]
E --> F["Regarder le comportement de fetch et la limite de requête : où lazy/eager/OSIV forcent-ils l'exécution du SQL en dehors du cas d'utilisation ?"]
F --> G["Choisir 1 premier mouvement de refactorisation"]
G --> H["Vérifier avec un test SQL/Statistics/ORM-regression"]
Notez que l'objectif n'est pas « tout de suite parfait », mais un premier mouvement qui maximise la prévisibilité. C'est comme faire le ménage dans un appartement : si vous essayez de tout laver immédiatement, vous vous retrouverez assis par terre au milieu des chiffons à regarder le vide. Un premier mouvement, c'est « d'abord dégager la table pour avoir de la place pour préparer la suite ».
2. Cas d'utilisation et données nécessaires
Il est agréable de commencer l'audit de persistance par le plus « ennuyeux » : la lecture d'une méthode de service avec les yeux de quelqu'un qui ne connaît pas votre projet. C'est là qu'on découvre généralement que la méthode s'appelle loadOrders, mais qu'à l'intérieur il y a à la fois de la lecture, de l'écriture, du formatage de rapport, et soudainement un appel à saveAndFlush() « pour être sûr ». Un cas d'utilisation est comme un mini-scénario : s'il est lui-même flou, Hibernate n'en sera pas responsable, même s'il essaie très fort.
D'un point de vue pratique, il est utile de répondre d'abord à deux questions. Premièrement, s'agit-il d'un cas de lecture ou d'un cas d'écriture. Deuxièmement, quel est le « résultat correct » : un graphe d'entités ou un modèle de lecture spécialement formé. Une fois que vous énoncez cela, beaucoup de choses deviennent suspectes d'elles-mêmes : par exemple, un « cas de lecture pour une liste de commandes » n'a presque jamais à renvoyer List<PurchaseOrder>.
Exemple rapide : une entité fuit depuis un service de lecture
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OrderReadService {
private final OrderRepository orderRepository;
OrderReadService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public PurchaseOrder getOrder(Long id) {
// Dans un cas de lecture, une entité est renvoyée vers l'extérieur : n'importe qui peut ensuite déclencher des chargements lazy
// (dans la sérialisation, dans le logger, dans le mapper, etc.), et la forme de lecture deviendra implicite.
return orderRepository.findById(id).orElseThrow();
}
}
Le code est « correct » dans le sens où il compile et fonctionne même. Mais l'audit pose la question suivante : qui contrôle désormais ce qui sera exactement chargé depuis la base de données ? La réponse est désagréable : celui qui a reçu l'entité et a commencé à naviguer dedans, peut-être même en dehors de la transaction, peut-être dans le logger, peut-être dans la sérialisation. La forme de lecture est donc devenue implicite.
À ce stade, une seule question ferme suffit généralement. S'il s'agit d'un cas de lecture, pourquoi une entité est-elle renvoyée à l'extérieur au lieu d'un modèle de lecture étroit comme OrderSummary(...) ? S'il s'agit d'un cas d'écriture, où est alors la véritable unité de travail et pourquoi la méthode ressemble-t-elle à une lecture ? Cette seule question sépare rapidement « nous avons besoin d'un DTO/projection/query » de « nous avons besoin d'un agrégat spécifique pour la modification ».
Projection et le constructeur de DTO ne sont pas non plus de la magie. Dès que vous tirez sur des propriétés imbriquées profondément, vous choisissez déjà le join, la largeur du SELECT et le coût de la lecture. Mais c'est au moins une décision consciente au niveau du cas d'utilisation, et non un effet secondaire de celui qui appellera plus tard un getter.
3. Drapeaux rouges : scan rapide
Lorsque vous avez déjà compris le cas d'utilisation, vous voulez parcourir rapidement le code et trouver les « odeurs » qui mènent presque toujours au chaos dans la couche de persistance. La bonne nouvelle : les anti-patrons de persistance ont une apparence assez répétable. La mauvaise nouvelle : l'IDE ne les soulignera pas en rouge. Mais on peut les attraper en prenant l'habitude de poser les mêmes questions à chaque méthode.
Pour ne pas transformer l'audit en « question de goût », il est pratique de penser ainsi : chaque drapeau rouge doit avoir un premier petit mouvement qui améliore la prévisibilité. Pas « tout réécrire », mais un pas concret : renvoyer un DTO, supprimer un flush, fusionner une boucle, faire une requête explicite, réduire la transaction. Certains de ces drapeaux concernent les requêtes superflues, d'autres la fuite du modèle d'écriture, et il est utile d'avoir les deux groupes sous les yeux.
| Drapeau rouge dans le code | Pourquoi c'est dangereux pour le comportement ORM | Premier mouvement raisonnable (pas une révolution) |
|---|---|---|
| Le service renvoie une entité depuis un cas de lecture | La forme des données et les points de chargement deviennent implicites, le risque de relecture lazy accidentelle et de N+1 augmente | Créer un modèle de lecture (DTO/projection) adapté au scénario |
| @Transactional contient du « post-traitement » : formatage, assemblage de chaînes, calculs complexes | La transaction devient overscoped, des flush et des lazy-load superflus peuvent s'y produire | Sortir le post-processing de la transaction, ne laisser à l'intérieur que l'unité de travail |
| findById() en boucle sur une liste d'identifiants | C'est le chemin direct vers chatty repository et le « N+1 manuel », même si le plan de fetch est normal | Fusionner en une seule requête (findAllById, IN, query repo) |
| saveAndFlush() « au cas où » après modification d'une entité gérée | Souvent un flush superflu et une confusion inutile (jusqu'à des SELECT superflus venant de la sémantique de merge) | Supprimer saveAndFlush, laisser le dirty checking (ou faire un flush conscient) |
| EAGER mis « pour ne pas avoir à réfléchir » | EAGER ne garantit pas un SQL optimal, gonfle le graphe, crée des secondary selects | Revenir à LAZY et faire un plan de fetch par cas d'utilisation (explicit query / graph / join fetch) |
| Le SQL part dans le contrôleur, la sérialisation ou les logs ; le projet vit avec open-in-view=true | La session de limite de requête masque l'absence de conception de fetch, et le cas d'utilisation cesse de contrôler le moment des requêtes | Ramener le chargement à l'intérieur du cas d'utilisation, garder open-in-view=false, fixer le modèle de lecture / le plan de fetch |
| Une racine tire des collections non liées et des règles de cycle de vie dans la plupart des cas d'utilisation | Giant graph s'avère être non seulement un problème de fetch, mais aussi un modèle d'écriture trop large | Revoir la limite de l'agrégat : ce qui doit réellement changer atomiquement ensemble |
| Le repository est perçu comme « il choisira lui-même le bon coût » | L'API du repository ne conçoit pas le cas d'utilisation et ne fixe pas la forme de lecture | Donner des méthodes orientées cas d'utilisation : findForEditing, findSummaries... |
Ce tableau n'est pas une « vérité éternelle », mais une façon simple de discipliner la pensée. Si vous voyez l'un des drapeaux rouges, vous n'êtes pas obligé de tout réparer immédiatement. Mais vous devez honnêtement dire : « oui, ici le coût et le comportement sont implicites ».
4. Constats et premier mouvement
La force de l'audit n'est pas de savoir critiquer. La force est de savoir enregistrer une observation et la lier à un premier pas concret. Il est très utile de penser en termes de tels « constats » (findings), pour que la discussion lors de la revue de code soit concrète et courte. Sinon, la conversation se transforme en un éternel : « bon, il y a quelque chose qui ne va pas avec Hibernate ».
On peut commencer par un format simple : nous avons une description du problème et un premier mouvement. Cela discipline même un développeur seul, et en équipe, cela réduit en plus l'émotivité des discussions (personne n'aime quand son code est qualifié d'« horrible », mais presque tout le monde accepte normalement la phrase « voici le symptôme, voici le premier pas »).
Mini-format de constat dans le code et les notes de revue
// Un « Finding » n'est pas un rapport de bug, mais un court lien : symptôme → premier petit pas.
record Finding(String problem, String firstMove) {}
Et un exemple d'ensemble de findings :
import java.util.List;
class AuditNotes {
// Important : chaque symptôme a un « premier secours » qui peut vraiment être fait rapidement.
List<Finding> findings = List.of(
new Finding("fuite d'entité (entity leakage)", "retourner OrderSummary au lieu de PurchaseOrder"),
new Finding("SQL dans le web/request scope", "ramener le chargement à l'intérieur du cas d'utilisation et garder open-in-view=false"),
new Finding("repository en boucle", "remplacer la recherche par identifiant par une seule requête"),
new Finding("limite d'agrégat trop large", "séparer la forme de lecture des données qui changent réellement dans une seule unité de travail"),
new Finding("saveAndFlush inutile", "laisser la mise à jour au dirty checking")
);
}
Ce qui importe ici n'est pas la beauté du code Java, mais l'idée : chaque symptôme doit avoir un « premier secours ». De plus, ce premier secours doit être tel qu'on puisse vraiment le faire sans une semaine de migrations et sans casser la moitié des tests.
Si le SQL est déjà parti dans le contrôleur/JSON ou qu'une racine tire des collections non liées pour la moitié des scénarios, un tel constat est souvent plus important que l'optimisation cosmétique d'une seule requête. Ici, ce sont les limites mêmes du cas d'utilisation qui sont brisées.
Priorité : quel constat réparer en premier
Dans le cadre du sujet d'aujourd'hui, il est utile de garder un principe de priorité simple. On répare d'abord ce qui augmente le plus la prévisibilité de la forme des données et des limites du cas d'utilisation. En pratique, cela signifie souvent : d'abord supprimer la fuite d'entité (ou au moins la limiter), puis fusionner le chatty repository, puis seulement toucher aux habitudes de flush et aux stratégies de fetch. Mais ce n'est pas une règle stricte, c'est une façon de ne pas se noyer dans des améliorations sans fin.
5. Vérification de l'effet : SQL et test
Une mauvaise habitude dans la refactorisation du code de persistance est de se dire : « bon, c'est devenu plus joli ». La beauté est bonne, mais Hibernate sait être beau, tout en produisant un SQL très bavard. Par conséquent, l'audit et la refactorisation doivent se terminer par une vérification observable : soit vous regardez la trace SQL, soit, mieux encore, vous fixez le comportement par un test. L'idéal est les deux. Mais même une vérification minimale discipline déjà : après la correction, il faut voir si le SQL n'est pas parti dans le request scope, si le nombre de requêtes n'a pas augmenté et s'il n'y a pas eu de UPDATE superflu.
L'essence d'un test de régression ORM est simple : il vérifie non seulement que « les données sont revenues », mais aussi que l'ORM n'a rien fait d'inattendu, par exemple n'a pas exécuté 51 requêtes au lieu de 2. Avec un tel test, il est difficile de discuter : soit le nombre de requêtes a diminué, soit il n'a pas diminué. Hibernate peut discuter, mais généralement en silence. C'est particulièrement utile pour attraper les cas où le chemin heureux semble innocent, mais le SQL part dans la sérialisation, les logs ou un autre code externe.
Exemple : activer les statistiques Hibernate dans @DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
@DataJpaTest
@TestPropertySource(properties = {
// Activer la collecte des statistiques Hibernate pour que le test puisse « tâter » le nombre de requêtes.
"spring.jpa.properties.hibernate.generate_statistics=true"
})
class OrderQueryTest {
}
Ce n'est pas la seule façon, mais c'est compact et bien adapté à une base de code de laboratoire.
Exemple : relever le nombre d'instructions SQL préparées
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
class HibernateStats {
static long preparedStatements(EntityManagerFactory emf) {
// Extraire la Hibernate SessionFactory de JPA pour accéder aux statistiques.
SessionFactory sf = emf.unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
// La métrique est simple : combien d'instructions SQL préparées depuis le démarrage.
return stats.getPrepareStatementCount();
}
}
Oui, ce n'est pas une « métrique idéale du monde », mais elle aide à attraper des régressions du style « pourquoi le nombre de requêtes a-t-il été multiplié par 10 ? ». Et pour un projet d'apprentissage, c'est un outil tout à fait honnête.
Mini-test : vérifier que le cas de lecture ne dérive pas
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class OrderQueryTest {
@Autowired EntityManagerFactory emf;
@Autowired OrderQueryRepository orderQueryRepository;
@Test
void findSummaryById_shouldBeCheap() {
// Important : le compteur s'accumule, donc le test compare « avant/après » un seul appel.
long before = HibernateStats.preparedStatements(emf);
// Nous vérifions ici le « coût » de la lecture, et pas seulement l'exactitude des données.
orderQueryRepository.findSummaryById(1L);
long after = HibernateStats.preparedStatements(emf);
assertThat(after - before).isLessThanOrEqualTo(2);
}
}
Dans un monde idéal, vous contrôleriez plus précisément l'environnement et la remise à zéro des statistiques. Mais même une telle forme discipline déjà : si quelqu'un a « accidentellement » renvoyé une entité et a commencé à toucher aux relations lazy, le test commencera à alerter.
6. Cas pratique : de la « moissonneuse-batteuse » au cas de lecture
Le format le plus utile de l'audit est de prendre une méthode problématique et de lui appliquer notre approche : cas d'utilisation → drapeaux rouges → constats → première refactorisation → vérification. Faisons-le sur une typique « moissonneuse-batteuse » qui à la fois lit, écrit et formate le résultat. Une telle méthode est souvent écrite non pas parce que le développeur est mauvais, mais parce qu'il était pressé, et que Hibernate, à ce moment-là… notait silencieusement tout dans son carnet.
Avant l'audit : méthode avec un ensemble de drapeaux rouges
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
class ProblematicOrderService {
private final OrderRepository orderRepository;
ProblematicOrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public List<PurchaseOrder> loadOrders(List<Long> ids) {
List<PurchaseOrder> result = new ArrayList<>();
for (Long id : ids) {
// Drapeau rouge : findById en boucle → chatty repository et "N+1 manuel" presque garanti.
PurchaseOrder order = orderRepository.findById(id).orElseThrow();
// Drapeau rouge : saveAndFlush sans modification explicite → flush superflu « au cas où ».
// Drapeau rouge : une entité est renvoyée vers l'extérieur → des relectures lazy implicites vont suivre.
result.add(orderRepository.saveAndFlush(order));
}
return result;
}
}
Si on regarde avec les yeux d'un auditeur, c'est presque un combo. Le renvoi d'entité vers l'extérieur suggère une fuite d'entité. Le repository en boucle est un chatty repository. saveAndFlush() sans modification explicite est une véritable demande à Hibernate : « fais quelque chose… s'il te plaît ». Et tout cela dans une seule transaction, alors que nous ne comprenons même pas s'il s'agit d'un cas de lecture ou d'écriture.
Étape 1 : formuler le cas d'utilisation en langage humain
Est-ce une lecture ou une écriture ? D'après le nom loadOrders et l'absence de modifications, c'est une lecture. Alors, @Transactional sans readOnly = true est déjà suspect, et saveAndFlush() ressemble à un bouton « parce que c'est plus rassurant ». Donc, notre premier mouvement de refactorisation doit rendre la lecture explicite et la rassembler en une seule requête.
Étape 2 : premier mouvement — renvoyer un modèle de lecture et fusionner le chatter
Créons un modèle de lecture :
package com.example.commerce.orders.dto;
// DTO pour un scénario spécifique : délibérément peu de champs pour que la lecture soit prévisible.
public record OrderSummary(Long id, String orderNumber) {}
Et un repository de requête :
import com.example.commerce.orders.dto.OrderSummary;
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.util.List;
interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {
@Query("""
select new com.example.commerce.orders.dto.OrderSummary(o.id, o.orderNumber)
from PurchaseOrder o
where o.id in :ids
""")
// Une seule requête au lieu d'une boucle sur les identifiants : moins de bavardage avec la BDD, plus facile de contrôler le SQL.
List<OrderSummary> findSummariesByIds(@Param("ids") List<Long> ids);
}
Maintenant, le service devient court et honnête :
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
class OrderAuditReadService {
private final OrderQueryRepository orderQueryRepository;
OrderAuditReadService(OrderQueryRepository orderQueryRepository) {
this.orderQueryRepository = orderQueryRepository;
}
@Transactional(readOnly = true)
public List<OrderSummary> loadOrderSummaries(List<Long> ids) {
// Cas de lecture : on renvoie un modèle de lecture et on ne maintient pas la transaction plus longtemps que nécessaire pour la lecture.
return orderQueryRepository.findSummariesByIds(ids);
}
}
Qu'avons-nous gagné ? Nous avons rendu la forme des données explicite, supprimé saveAndFlush(), supprimé findById() en boucle, et précisé la transaction comme read-only. C'est ce qu'on appelle un « premier mouvement » : non pas une réécriture du modèle, mais une mise en forme compréhensible du cas d'utilisation.
Si l'audit montre que la méthode n'est en fait pas un cas de lecture mais un cas d'écriture, le mouvement est différent : on charge l'agrégat spécifique pour la modification, on fixe explicitement le plan de fetch nécessaire, et la modification elle-même reste à l'intérieur d'une unité de travail étroite. Ici, ce n'est plus un DTO de résumé, mais un agrégat contrôlé. Il est important de ne pas mélanger ces deux modes dans une même méthode « universelle ».
7. Erreurs typiques lors de l'audit de persistance
L'audit de persistance peut facilement devenir un outil utile pour l'équipe, mais il peut aussi facilement se transformer en un ennui sans fin dont tout le monde voudra se cacher dans le package voisin. Les erreurs ici ne sont généralement pas techniques, mais méthodologiques : comment exactement vous regardez le code et quelles conclusions vous en tirez. Voici les râteaux les plus fréquents sur lesquels même de bons développeurs marchent s'ils n'ont pas l'habitude de mener l'audit à un résultat concret.
Erreur n°1 : commencer l'audit par les annotations plutôt que par le cas d'utilisation.
On a très envie d'ouvrir une entité, de voir @ManyToOne(fetch = EAGER) et de commencer à corriger. Mais si vous ne comprenez pas s'il s'agit d'un cas de liste, d'un cas de détail ou d'un cas d'écriture, vous traitez les symptômes. Le bon rythme est différent : d'abord lire la méthode de service comme un scénario, comprendre l'entrée/la sortie, et seulement ensuite regarder quels mécanismes d'Hibernate elle déclenche.
Erreur n°2 : considérer que « peu de requêtes » signifie « tout va bien ».
Parfois, il y a effectivement peu de requêtes, mais une seule requête tire une jointure géante et matérialise la moitié de la base de données parce que le graphe est énorme. Et inversement : parfois, quelques requêtes avec du batch fetching sont un compromis normal. L'audit ne doit pas se focaliser uniquement sur le chiffre, il doit examiner la prévisibilité de la forme des données et l'adéquation au cas d'utilisation.
Erreur n°3 : faire une « refactorisation géante » au lieu d'un premier mouvement.
Si vous avez trouvé une fuite d'entité, un chatty repository et une transaction overscoped, la tentation est de réécrire tout le module. Généralement, cela se termine par le fait que vous avez réécrit la moitié, que vous êtes fatigué, et que c'est devenu encore plus effrayant. Dans une approche d'audit saine, vous choisissez un premier mouvement que vous pouvez terminer aujourd'hui : renvoyer un OrderSummary au lieu d'une entité, fusionner la boucle en une seule requête, supprimer saveAndFlush().
Erreur n°4 : mélanger la lecture et l'écriture dans une même méthode « universelle ».
C'est une source classique de chaos : la méthode s'appelle loadOrders, mais à l'intérieur elle met à jour le statut, écrit un audit et formate un rapport. En conséquence, vous ne pouvez pas mettre un @Transactional(readOnly = true) raisonnable, vous ne pouvez pas choisir un plan de fetch adapté au scénario, et chaque développeur suivant a peur de toucher au code. L'audit doit séparer, doucement mais fermement : cas de lecture d'un côté, cas d'écriture de l'autre.
Erreur n°5 : ne pas vérifier la refactorisation avec le SQL/statistiques/test.
La situation la plus frustrante : vous avez « nettoyé le code », mais le SQL est devenu pire — simplement parce que le moment de l'initialisation des relations lazy a changé ou qu'il y a eu plus de lectures par hasard. Si après le changement vous n'avez pas regardé la trace SQL et n'avez pas consolidé le scénario critique avec un test de régression ORM, l'audit se transforme en procédure décorative.
GO TO FULL VERSION