1. Objectif correct de la correction lazy
Lorsque l'application plante avec LazyInitializationException, le cerveau, surtout fatigué, propose des solutions rapides comme « coller du scotch et faire semblant que ça n'arrive pas ». C'est normal : ça arrive à tout le monde. Mais dans la couche de persistance, ces « victoires rapides » se transforment souvent en défaites lentes : les requêtes SQL se produisent à des endroits inattendus, des ralentissements difficiles à expliquer apparaissent, et le code devient imprévisible.
La correction correcte commence par un changement d'objectif. Nous ne « supprimons pas l'exception » comme symptôme, mais nous faisons de la lecture des données une partie d'une opération de service terminée, à l'intérieur de laquelle vit la transaction et le persistence context est disponible. Ainsi, le mécanisme lazy devient simplement un outil d'économie, et non un piège. Et le plus agréable : le SQL « vit » à nouveau là où il doit être – à côté du code qui formule le use case.
Pour le dire en une phrase : le lazy loading ressemble à la livraison de repas : tant que le restaurant est ouvert (il y a une session active), vous pouvez commander un plat supplémentaire ; lorsque le restaurant est fermé (la transaction est terminée), essayer de « commander en plus » est inutile et un peu triste.
Après avoir parlé de no Session et de la tentation de simplement laisser la Session vivre plus longtemps, le tableau est déjà assez honnête. Un correctif normal ne se situe ni dans la couche Web ni dans un drapeau de configuration, mais dans la forme même de l'opération de service : la lecture doit se terminer là où Hibernate a encore un persistence context actif. Par conséquent, nous ne réparerons pas le symptôme, mais la limite de lecture.
Définir les données nécessaires à la lecture
L'erreur logique la plus fréquente est de penser que si quelque part on a besoin d'une « commande », on doit retourner toute la PurchaseOrder. Si on a besoin d'un « produit », on doit retourner tout le Product. C'est pratique… jusqu'à la première relation paresseuse. Ensuite, nous obtenons une entité qui semble « réelle », mais qui contient en interne « pas encore chargé, revenez avec une transaction active ».
Par conséquent, avant d'écrire du code, il est utile (et surtout peu coûteux en temps) de répondre à la question : qu'est-ce qui est réellement nécessaire à l'extérieur ? Pas « quelle entité », mais « quels champs/valeurs ». Par exemple, « pour la fiche produit, j'ai besoin d'une chaîne de description » ou « pour un récapitulatif de commande, j'ai besoin du numéro et du nombre de lignes ». Et là, il s'avère soudain qu'il n'est pas nécessaire de retourner une entity du tout.
Voici un petit tableau aide-mémoire pour la réflexion (ce n'est pas un dogme, mais une façon pratique de formuler l'exigence) :
| Scénario de lecture (use case) | Ce qui est réellement nécessaire à l'extérieur | Où est-il préférable de le préparer | Ce qui se passe si on retourne l'entité et qu'on espère lazy |
|---|---|---|---|
| Fiche produit | sku, name, description depuis ProductDetails | Dans CatalogService à l'intérieur de @Transactional(readOnly=true) | product.getDetails().getDescription() peut échouer en dehors de la transaction |
| Récapitulatif de commande | orderNumber, itemCount | Dans OrderQueryService à l'intérieur de la transaction | order.getItems().size() peut échouer en dehors de la transaction |
| Liste des noms de lignes | liste de productNameSnapshot | Dans le service : lire order.getItems() et assembler la liste | dans le contrôleur/code externe, le parcours de la collection peut facilement provoquer une exception |
Remarquez le motif commun : nous ne contestons pas lazy en tant que phénomène. Nous nous contentons de déplacer le moment d'accès aux données là où Hibernate a le droit (et la possibilité) d'effectuer une requête SQL.
3. Limite de lecture : service + @Transactional(readOnly=true)
Il est très tentant de percevoir @Transactional comme une « annotation pour l'écriture », et de considérer les lectures comme « sûres » et « ne nécessitant pas de transaction ». Dans Hibernate, c'est une illusion dangereuse : pour le chargement paresseux, il ne faut pas un droit moral, mais un fait concret : la session doit être ouverte. Or, la session dans une application Spring typique vit justement dans le cadre de la transaction d'une méthode de service.
Par conséquent, la limite de lecture correcte ressemble à ceci : le code externe (contrôleur, commande console, test, autre service) appelle une seule méthode de service, et cette méthode, à l'intérieur de la transaction, obtient toutes les données nécessaires et retourne à l'extérieur un résultat déjà prêt. De plus, readOnly = true est utile ici non pas comme « accélérateur en mode turbo », mais comme déclaration d'intention : « je lis et je ne prévois pas de modifier ». Cela réduit le risque de modifications accidentelles de l'entité gérée (et nous nous souvenons que le dirty checking peut réserver des surprises).
Schématiquement, le flux correct peut être représenté ainsi :
flowchart TD
A["Code externe (controller/test/commande)"] --> B["@Transactional(readOnly=true) Service method"]
B --> C["EntityManager.find(...)"]
B --> D["Accès aux relations lazy dans la transaction (proxy/collection initialisés)"]
B --> E["Assemblage du résultat (String/DTO/List)"]
E --> A
Le point clé : nous ne « trimballons » pas une entité semi-initialisée à l'extérieur en espérant qu'elle « finira toute seule » de se charger. Nous retournons immédiatement ce qui est réellement nécessaire.
4. Retourner une valeur, pas une entité : exemple avec ProductDetails
Parfois, corriger un problème lazy signifie littéralement une ligne… si l'on admet à l'avance que l'extérieur n'a pas besoin de l'objet entier. Imaginez que UI/code externe souhaite afficher seulement la description d'un produit. Retourner Product pour une seule ligne, c'est comme louer un camion pour livrer un petit pain. Techniquement ça marche, mais ça semble suspect.
Peu importe quel lazy to-one vous avez dans votre projet – customer, details ou autre – le principe est le même : les données nécessaires doivent être lues dans la transaction, et à l'extérieur on retourne déjà le résultat prêt.
Dans Commerce Persistence Lab, cela peut être formalisé comme une méthode de lecture dans le service de catalogue. Remarquez : nous lisons délibérément la description à l'intérieur de la transaction. Même si details est un lazy proxy, il aura le temps de s'initialiser « sur place ».
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogReadService {
private final EntityManager entityManager;
public CatalogReadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public String loadProductDescription(Long productId) {
// Important : la lecture de l'entité se fait dans la transaction, donc le contexte de persistance est disponible
Product product = entityManager.find(Product.class, productId);
// Important : l'accès à la relation lazy se fait ici même, dans la transaction (et non pas ailleurs à l'extérieur)
return product.getDetails().getDescription();
}
}
Il est important de saisir deux idées. Premièrement, product.getDetails() n'est pas obligé d'effectuer immédiatement une requête SQL : il peut s'agir simplement d'une référence/proxy. Deuxièmement, getDescription() – c'est déjà un « accès réel aux données », et c'est celui-ci qui peut déclencher un SELECT. Mais nous le faisons là où Hibernate a une session ouverte, donc il n'y aura pas de problème.
Si vous avez besoin de plusieurs champs, l'idée ne change pas : « récupérer l'entité, toucher aux champs lazy nécessaires à l'intérieur de la transaction, retourner les valeurs prêtes à l'extérieur ».
5. Assembler un petit DTO dans la transaction : exemple avec OrderSummary
Un scénario un peu plus réaliste : le code externe ne veut pas « toute la commande », mais un bref résumé. Par exemple, le numéro de commande et le nombre de lignes. C'est un cas typique où retourner une entity n'est pas pratique : d'une part, elle traîne avec elle la collection lazy items ; d'autre part, à l'extérieur apparaîtront des tentations de « fouiller encore un peu à l'intérieur » et de déclencher accidentellement du SQL hors transaction.
Ici, un simple DTO fait bien le travail. En Java 25 (et en général dans le Java moderne), il est commode d'utiliser un record : c'est compact et lisible.
// DTO pour un résumé court : seul ce qui est réellement nécessaire au use case sort à l'extérieur
public record OrderSummary(String orderNumber, int itemCount) {
}
Et maintenant la méthode de service. Important : order.getItems().size() n'est pas une « opération bon marché par défaut ». Pour une collection lazy, cela peut être le moment de l'initialisation. Nous sommes donc obligés de le faire à l'intérieur de la transaction.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final EntityManager entityManager;
public OrderQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderSummary loadOrderSummary(Long orderId) {
// Lire la commande dans la transaction
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Important : size() peut déclencher le chargement de la collection lazy items (c'est-à-dire exécuter SQL)
int itemCount = order.getItems().size();
// Retourner le DTO : le code externe ne pourra plus « accidentellement » toucher aux relations lazy
return new OrderSummary(order.getOrderNumber(), itemCount);
}
}
Techniquement, tout est très « humain » : le service a lu les données, préparé le résultat, retourné à l'extérieur. Le code externe ne peut plus « toucher » accidentellement à la collection lazy – il a déjà un nombre, et non une mine potentielle.
6. Initialisation des relations lazy dans le service
Dans un monde idéal, on retourne des DTO et des primitives, et les entity restent dans les limites de la couche de données et du service. En réalité, il peut arriver que vous soyez dans une phase intermédiaire : le code est déjà écrit de façon à retourner l'entité, mais vous voulez au moins rendre le comportement prévisible et éviter les plantages. Ce n'est pas un « meilleur style pour toujours », mais comme solution transitoire, cela se rencontre.
La façon la plus simple d'« initialiser une collection » est de la toucher à l'intérieur de la transaction. Par exemple, appeler size() ou parcourir avec une boucle. Oui, cela ressemble un peu à un rituel (Hibernate aime parfois ces rituels), mais le sens est rationnel : vous forcez l'ORM à effectuer la requête SQL nécessaire là où c'est permis.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderLoadService {
private final EntityManager entityManager;
public OrderLoadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PurchaseOrder loadOrderInitialized(Long orderId) {
// Lire la commande dans la transaction
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Initialisation de la collection lazy : forcer l'ORM à charger items ici, et non pas « à l'extérieur »
order.getItems().size();
// Retourner l'entité comme solution transitoire (mieux vaut retourner un DTO, mais parfois c'est nécessaire)
return order;
}
}
L'avantage de cette approche est qu'elle supprime effectivement LazyInitializationException pour la relation spécifique items. L'inconvénient est qu'elle ne résout pas le problème de façon systémique : le code appelant peut toujours aller chercher une autre relation lazy (par exemple, order.getCustomer().getEmail()), et tout se reproduira. Considérez donc cela comme un « désamorçage ponctuel », et non comme une architecture finale.
Parfois, les développeurs essaient de faire une « magie encore plus explicite » et utilisent des méthodes spécifiques à Hibernate. Par exemple :
import org.hibernate.Hibernate;
// Initialisation via l'API Hibernate : fonctionne mais lie le code à Hibernate
Hibernate.initialize(order.getItems());
Cela fonctionne sur la même idée (initialiser dans la transaction), mais lie le code à l'API Hibernate. Dans un projet pédagogique, nous essayons de rester proches d'une pensée compréhensible en JPA, nous montrons donc plus souvent l'approche « normale » en accédant à la collection comme déclencheur.
7. Exemple complet de lecture des détails de commande
Maintenant, rassemblons tout dans un scénario aussi pratique que possible, proche de ce qui est réellement nécessaire dans Commerce Persistence Lab. Supposons que le code externe (contrôleur, test, commande console) doive afficher les « détails de la commande » : le numéro de commande et les lignes. Nous n'avons pas besoin de tout le graphe d'entités, nous n'avons pas besoin de retourner OrderItem comme entity, et pour les lignes, les champs snapshot (par exemple, productNameSnapshot, productSkuSnapshot) sont particulièrement utiles.
Créons deux petits DTO :
// DTO d'une ligne de commande : utiliser les champs snapshot pour ne pas tirer les entités associées
public record OrderItemLine(String productSku, String productName, int quantity) {
}
import java.util.List;
// DTO « détails de commande » : numéro + liste prête de lignes (sans collections persistantes ni proxies)
public record OrderDetailsDto(String orderNumber, List<OrderItemLine> items) {
}
Et le service qui, dans la transaction, initialise items, les parcourt et assemble le DTO. Ici, il est important que nous prenions précisément les champs snapshot pour ne pas provoquer de lectures lazy supplémentaires d'entités associées.
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadFacade {
private final EntityManager entityManager;
public OrderReadFacade(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderDetailsDto loadOrderDetails(Long orderId) {
// Trouver la commande dans la transaction (tant que le « restaurant est ouvert »)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Assembler le DTO dans la transaction pour ne pas exposer les objets lazy à l'extérieur
List<OrderItemLine> lines = new ArrayList<>();
for (OrderItem item : order.getItems()) {
// Important : prendre les champs snapshot de OrderItem pour ne pas déclencher le chargement lazy de Product
lines.add(new OrderItemLine(
item.getProductSkuSnapshot(),
item.getProductNameSnapshot(),
item.getQuantity()
));
}
// Retourner déjà le résultat prêt du use case
return new OrderDetailsDto(order.getOrderNumber(), lines);
}
}
Qu'avons-nous obtenu en pratique ? Le code externe reçoit un objet déjà complètement prêt à l'emploi : il n'y a ni proxies paresseux, ni collections persistantes, ni risque de « toucher accidentellement et planter ». Et Hibernate, s'il doit aller en base de données pour items, le fera dans la transaction, dans le service, à un endroit compréhensible.
C'est l'idée centrale de cette leçon : lire les données fait partie du use case. Si vous construisez une opération de lecture, elle doit se terminer par la sortie d'un « résultat prêt », et non d'un « objet qui pourrait théoriquement encore lire des données s'il est placé magiquement correctement dans l'espace-temps ».
8. Vérification de la correction : requêtes SQL et initialisation
Après le correctif, vous voulez être sûr non seulement que « l'erreur a disparu », mais aussi que le SQL est devenu prévisible. Parce qu'on peut « réparer » de manière à ce que tout cesse de planter, mais que les requêtes soient effectuées silencieusement à des endroits inattendus – et vous échangez simplement une douleur contre une autre (généralement plus coûteuse).
La première façon, la plus honnête, est de regarder le log SQL dans le profil sql-trace et de vérifier que les requêtes sont exécutées pendant l'exécution de la méthode de service, et non après. Dans la couche de persistance, la visibilité SQL fait partie intégrante de la réflexion, ce n'est donc pas une « option supplémentaire », mais un outil de travail.
La deuxième façon est de vérifier ponctuellement si la relation est initialisée lorsque vous vous y attendez. Hibernate fournit l'utilitaire Hibernate.isInitialized(...). Il ne doit pas devenir une « logique métier », mais pour la compréhension du mécanisme en laboratoire, il est utile.
import org.hibernate.Hibernate;
// Vérification diagnostique : la collection est-elle initialisée (dans la transaction, c'est particulièrement révélateur)
boolean initialized = Hibernate.isInitialized(order.getItems());
System.out.println("items initialized = " + initialized); // items initialisé = true/false
Le sens de cette vérification est simple : si vous avez déjà parcouru order.getItems() dans la transaction, alors initialized doit devenir true. Si vous n'avez pas touché à la collection, ce sera probablement false. Et alors vous comprendrez facilement pourquoi tout plante lors d'un accès depuis l'extérieur.
Le critère le plus important d'une « correction correcte » est le suivant : après la correction, vous avez une réponse à la question « où exactement se fait la lecture des données ? ». Si la réponse est « à l'intérieur de l'opération de service », alors vous avez fait ce que vous vouliez.
Il est important de distinguer deux tâches. Nous avons déjà corrigé la correctness : les données sont lues au bon endroit et ne font pas planter le code en dehors de la transaction. Mais cela ne rend pas encore la lecture automatiquement efficace : à l'intérieur de la bonne limite de service, on peut tout à fait collecter des requêtes inutiles si le plan de fetch est choisi maladroitement.
9. Erreurs typiques lors de la correction des problèmes lazy
Erreur n°1 : activer OSIV ou passer les relations en EAGER pour que « ça ne plante pas ».
À court terme, c'est effectivement plus simple. À long terme, vous perdez le contrôle sur quand et pourquoi le SQL est exécuté, et l'application se transforme en quête « trouve l'endroit où cinq requêtes supplémentaires sont soudainement apparues ».
Erreur n°2 : retourner une entity depuis le service, et assembler le DTO à l'extérieur (dans le contrôleur ou un « formateur »).
Cela semble inoffensif (« je ne fais que construire la réponse »), mais c'est précisément à ce moment-là que vous accédez aux relations lazy après la transaction. Le correctif est presque toujours le même : déplacer l'assemblage du résultat à l'intérieur de la transaction, c'est-à-dire à l'intérieur de la méthode de service.
Erreur n°3 : « corriger » une LazyInitializationException en initialisant une collection via size(), et continuer à retourner l'entité à l'extérieur.
Cela crée une illusion de contrôle : aujourd'hui vous avez initialisé items à l'avance, demain quelqu'un va toucher à customer, après-demain à details, et le problème resurgit. Ce style se transforme rapidement en jeu « devine quels champs tu dois encore toucher à l'avance ».
Erreur n°4 : modifier accidentellement une entité gérée dans un scénario de lecture.
Si vous oubliez readOnly=true, ou si quelqu'un appelle un setter « juste pour le formatage » lors de la lecture, Hibernate peut décider qu'il s'agit d'une véritable modification et envoyer un UPDATE. Alors vous corrigez une erreur lazy, mais vous recevez en échange une surprise sous forme d'écriture en base.
Erreur n°5 : attraper LazyInitializationException et l'« avaler » avec un gestionnaire d'exceptions.
Cela conduit presque toujours à des données corrompues en sortie : certains champs sont vides, d'autres invalides, et l'utilisateur voit des résultats étranges. Dans la couche de persistance, l'exception est utile parce qu'elle dit honnêtement : « vous lisez des données là où il ne faut pas ». Si on la fait taire, vous perdez simplement le signal du bogue et continuez à vivre dans une architecture incorrecte.
GO TO FULL VERSION