CodeGym /Cours /Hibernate deep-dive /Lecture de listes par projection

Lecture de listes par projection

Hibernate deep-dive
Niveau 9, Leçon 4
Disponible

1. Deux listes backoffice dans Commerce Persistence Lab

La projection est facile à sous-estimer comme « oui, on peut couper des champs ». Mais sur une vraie liste, ce n’est plus du cosmétique, c’est un vrai refactoring avec un résultat très concret. Nous allons prendre deux scénarios typiques : le tableau des produits et le tableau des commandes dans le backoffice. Dans les deux cas, l’utilisateur de l’écran (et le code) n’a besoin que de quelques colonnes, pas de tout le graphe d’entités.

Fixons l’objectif de manière très concrète. Nous avons deux cas d’utilisation de lecture « liste » :

Le premier est la liste des Product. Généralement, dans un tableau de produits, on a besoin de id, sku, name, parfois du statut. Nous n’avons pas besoin de traîner ProductDetails, les catégories, les entités associées, et surtout nous n’avons pas besoin d’une entité managée qu’on pourrait modifier accidentellement pendant une « simple lecture ».

Le second est la liste des PurchaseOrder. Dans un tableau de commandes typique, on a besoin de id, orderNumber, du statut, de la date de création et de l’email du client. Notez : l’email se trouve dans Customer, donc la liste des commandes, dans sa version la plus simple, garantit presque que quelqu’un « innocemment » ira chercher order.getCustomer().getEmail() et obtiendra soit du N+1, soit une série de chargements lazy, soit la tentation de tout mettre en EAGER (et ensuite pleurer).

Point clé : nous n’« ajoutons pas un DTO de plus ». Nous formons un modèle de lecture pour des tableaux spécifiques et nous le fixons au niveau du repository et du service de requête.

Pour que l’idée ne se dissolve pas, on peut se la représenter ainsi :

flowchart TD
    UI["Backoffice list screen
(tableau, en quelque sorte)"] QS["Query Service
@Transactional(readOnly=true)"] R["Repository
@Query + projection"] DB[(PostgreSQL)] DTO["ProductListRow / OrderListRow
(read-model)"] UI --> QS --> R --> DB DB --> R --> DTO --> QS --> UI

Ici, le DTO n’est pas un « sac de transport de données ». C’est un contrat « ligne de tableau », pas un « objet du domaine pour vivre et se développer ».

2. Départ : colonnes du tableau et tri

On aimerait bien commencer le refactoring dans l’IDE : ouvrir le repository, remplacer le type de retour et appuyer fièrement sur Build. Mais si on fait ça, on obtient presque à coup sûr soit une projection de la taille d’une demi‑entité, soit trois variantes différentes du même DTO, soit un éternel « ajoutons encore deux champs, ça pourrait servir ». Donc la première étape n’est pas le code, mais la clarté : de quelles colonnes la liste a‑t‑elle réellement besoin aujourd’hui ?

Pour la liste des produits, convenons (pour l’exemple du cours) que nous avons besoin de id, sku, name. C’est suffisant pour voir le principe. Si votre écran affiche déjà en plus le statut ou le prix, pas de problème, vous ajouterez plus tard, mais il faut ajouter seulement ce qui est réellement affiché.

Pour la liste des commandes, convenons que nous avons besoin de id, orderNumber, customerEmail, status, createdAt. Cela montre déjà une chose importante : un champ (customerEmail) se trouve dans une autre entité. Donc soit nous faisons explicitement un join, soit nous commençons à vivre dans un monde de « chargements supplémentaires aléatoires ».

Le prochain morceau de discipline est un tri stable. Pour les produits, il est pratique de trier d’abord par name, puis par id, pour que des noms identiques ne cassent pas la prévisibilité. Pour les commandes, il est souvent commode de trier par createdAt desc, puis par id desc, pour que la page n°2 ne « bouge » pas à l’apparition de nouvelles commandes.

Si on veut retenir cela en une phrase, la voici : d’abord déterminer les colonnes et l’ordre de tri, ensuite choisir la projection, et seulement après écrire la requête. Sinon, on construit l’API à l’envers : « voilà l’entité, débrouillez‑vous pour en extraire ce qu’il vous faut ».

Pour ne pas reconstruire chaque liste à zéro, il est pratique d’avoir un workflow court :

  1. Fixer les colonnes et le tri par défaut.
  2. Choisir la forme du modèle de lecture : interface-based projection quand la liste est simple et monotable ; class-based DTO ou un @Query explicite quand on a besoin de jointures, de renommage de champs et qu’on veut contrôler visuellement la composition des colonnes.
  3. Faire en sorte que le repository retourne directement le modèle de lecture, pas l’entité.
  4. Ajouter Pageable et un tri stable via un critère de départage unique.
  5. Vérifier la trace SQL et la requête count si on renvoie une Page.

Ensuite, il suffit d’exécuter ce parcours sur Product et PurchaseOrder. Pour les deux exemples, nous restons dans la voie class-based : c’est plus simple pour voir à la fois la forme du résultat et les jointures elles‑mêmes, et pour les commandes c’est particulièrement utile.

3. Product : entity → ProductListRow

Sur Product, ce parcours est presque ennuyeux – et c’est son avantage. La liste est monotable et plate, donc une interface-based projection aurait aussi convenu. Mais pour ne pas se disperser sur deux branches différentes, nous gardons le class-based select new : il montre le plus explicitement possible la forme du résultat et se transpose ensuite sans surprise sur PurchaseOrder.

Imaginons que nous avions quelque chose de semblable dans le repository :

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Retourne une managed-entity : à l'intérieur de la transaction, c'est un objet Hibernate vivant
    // avec tous les "avantages" du persistence context (dirty checking, modifications accidentelles, etc.).
    Page<Product> findByStatus(ProductStatus status, Pageable pageable);
}

À première vue, tout est beau. Mais le problème est justement que la méthode est « trop universelle ». Elle retourne une managed-entity, donc à l’intérieur de @Transactional cette liste de produits devient une partie du persistence context avec tous ses attributs : snapshots, dirty checking, mises à jour accidentelles potentielles, et en plus la tentation d’aller chercher des associations lazy directement dans le rendu de la liste.

Création du modèle de lecture pour la ligne de liste

Le modèle de lecture est délibérément simple : le même ProductListRow, qui ne contient que id, sku et name. Cette forme suffit pour une ligne de catalogue, et elle sépare honnêtement la liste de l’entité Product.

Ajout d’une méthode de projection dans le repository

Maintenant, remplaçons la méthode « universelle » qui renvoie une entité par une vraie méthode de lecture qui retourne Page<ProductListRow>. Nous écrivons une requête JPQL explicite avec une expression constructeur, pour voir clairement quels champs sont sélectionnés.

import com.example.commerce.catalog.dto.ProductListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("""
           select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
           from Product p
           where p.status = :status
           """)
    // Important : la requête gère la forme du résultat, et l'ordre stable viendra de Pageable.
    Page<ProductListRow> findProductRowsByStatus(ProductStatus status, Pageable pageable);
}

Plusieurs points sont importants ici.

Premièrement, nous avons choisi seulement les colonnes nécessaires. Hibernate ne matérialisera pas Product comme une managed-entity, car nous ne sélectionnons pas p, nous sélectionnons new ....

Deuxièmement, la requête reste dans le même style class-based déjà connu via select new. Ce n’est donc pas une nouvelle référence, mais le même motif, seulement appliqué à la liste des produits.

Troisièmement, nous n’avons pas intégré le tri dans @Query. L’ordre des pages vit dans Pageable, pour que le tri par défaut soit visible là où nous composons réellement la liste.

Utilisation via un service de requête (couche fine)

Une bonne pratique dans le projet est de placer ces méthodes non pas comme « un repository de plus », mais comme un cas d’utilisation de lecture au niveau du paquet query. Ainsi, le service d’écriture du catalogue peut rester orienté entité, et le service de lecture orienté projection.

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // Déclarons l'intention : c'est une lecture, sans écritures accidentelles.
public Page<ProductListRow> listActiveProducts(int page, int size) {
    Sort sort = Sort.by("name").ascending()
            .and(Sort.by("id").ascending());

    PageRequest pageable = PageRequest.of(page, size, sort);

    return productRepository.findProductRowsByStatus(ProductStatus.ACTIVE, pageable);
}

Ainsi, le tri par défaut reste à côté du cas d’utilisation de lecture, et le repository ne commence pas à trimballer un order by fixe pour toutes les occasions.

SQL après la transition

Avant le refactoring, quand vous faisiez Page<Product>, Hibernate sélectionnait généralement toutes les colonnes de la table product (et parfois faisait aussi des requêtes secondaires inattendues si quelqu’un allait chercher des associations lazy).

Après le passage à la projection, le SQL devient « plat » et honnête : quelque chose comme ça (simplifié) :

-- Lit exactement les colonnes nécessaires à la ligne de la liste.
-- Le tri vient de Pageable, pas d'un ORDER BY codé en dur dans la requête.
select p.id, p.sku, p.name
from product p
where p.status = ?
order by p.name, p.id
limit ? offset ?;

Et vous voyez directement : « oui, je lis la table, oui, seulement trois colonnes, oui, seulement ce dont la liste a besoin ». Parallèlement, Spring Data effectue le count attendu. À ce moment, la liste se lit de manière prévisible : seulement les colonnes nécessaires, une requête de contenu plus un count, sans vie managée superflue autour.

4. PurchaseOrder : liste avec l’email du client

Si la liste des produits était un « échauffement », la liste des commandes est déjà plus proche de la réalité. Ici, il est très facile de se retrouver accidentellement dans une situation « je lis les commandes, puis je lis les clients séparément, puis encore autre chose… ». C’est cette liste qui engendre souvent le classique N+1 : on affiche 20 commandes, et pour chacune on affiche l’email du client – et voilà +20 requêtes sans aucune mauvaise intention, juste parce que le code « a l’air normal ».

Nous allons créer un modèle de lecture qui contient directement customerEmail, c’est‑à‑dire ce dont le tableau a réellement besoin, et l’obtenir en une seule requête via un join explicite.

DTO/record pour la ligne de liste des commandes

Le modèle de lecture est ici un peu plus riche : OrderListRow(id, orderNumber, customerEmail, status, createdAt). Ce qui importe n’est pas la longueur du résultat, mais le fait que customerEmail arrive comme partie de la ligne de la liste, et non comme prétexte pour aller ensuite chercher o.getCustomer().

Repository : join, Pageable et countQuery

Écrivons la méthode de requête. La nouveauté par rapport à Product n’est pas la projection elle‑même, mais le join explicite sur Customer et le countQuery explicite pour la Page.

import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    @Query(
        value = """
               select new com.example.commerce.orders.dto.OrderListRow(
                   o.id, o.orderNumber, c.email, o.status, o.createdAt
               )
               from PurchaseOrder o
               join o.customer c
               """,
        countQuery = """
               select count(o)
               from PurchaseOrder o
               join o.customer c
               """
    )
    Page<OrderListRow> findOrderRows(Pageable pageable);
}

Ici, le countQuery est montré explicitement, pas par rituel. Une Page pose deux questions différentes : quelles lignes afficher maintenant et combien de lignes existent au total. Dès que les join et les conditions se multiplient, un countQuery explicite retire la magie superflue et rend ce contrat plus transparent.

Et c’est justement là qu’on voit pourquoi le class-based DTO reste un bon choix pour une telle liste : le join et la composition des colonnes sont écrits directement dans la requête, au lieu d’être cachés derrière un « d’abord on renvoie PurchaseOrder, ensuite on trouvera bien comment obtenir l’email ».

Service en lecture seule et Pageable

Le service reste très fin : il définit un tri par défaut stable et le transmet au repository.

import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public Page<OrderListRow> listOrders(int page, int size) {
    Sort sort = Sort.by("createdAt").descending()
            .and(Sort.by("id").descending());

    PageRequest pageable = PageRequest.of(page, size, sort);

    return purchaseOrderRepository.findOrderRows(pageable);
}

Ainsi, l’ordre des pages reste là où il doit être pour un cas d’utilisation de liste : dans le Pageable, et la requête ne répond que de la forme du résultat et des jointures.

Projection au lieu de JOIN FETCH dans une liste

Il est utile ici de prendre conscience d’un point subtil souvent confondu après une journée passée sur les outils de fetch. Sur une liste de commandes, on aurait pu résoudre le problème de l’email du client avec un JOIN FETCH, c’est‑à‑dire charger quand même la PurchaseOrder managée et en même temps le Customer. En résultat, on a un « graphe vivant » là où on a besoin d’un résultat de lecture plat.

La projection résout le problème différemment : le join n’est nécessaire que pour la colonne customerEmail, pas pour charger tout le graphe d’entités. Ce n’est donc pas un concurrent des outils de fetch, mais souvent une approche plus correcte au niveau du choix du modèle de lecture.

5. Modèle de lecture dans les services et les transactions

Quand vous passez pour la première fois une liste à une projection, un dialogue intérieur typique de développeur apparaît : « Et maintenant, comment éditer le produit si la liste ne retourne pas Product ? ». C’est exactement le moment où nous comprenons : la liste et l’édition sont des cas d’utilisation différents. La liste retourne une ligne de tableau. L’édition retourne une entité ou charge une entité dans un scénario d’écriture.

Dans notre projet, on peut garder cela très simple : les services d’écriture restent orientés entité, et les services de requête deviennent orientés projection. Dans le paquet catalog.service vivent les méthodes de création et de mise à jour du produit, qui travaillent selon le motif find + mutate, gèrent les états et se reposent sur la sémantique managée. Dans le paquet catalog.query vit la lecture de la liste sous forme de Page<ProductListRow>. De même pour les commandes.

Avec la transaction aussi, ça devient plus simple, pas plus compliqué. Le résultat d’une projection n’est pas managé. Il n’est pas stocké dans le persistence context. Il n’a pas besoin de « vivre » dans une session ouverte. Cela signifie que vous dépendez moins des limites de la transaction et que vous risquez moins d’attraper une LazyInitializationException parce que quelqu’un est allé chercher des associations plus tard. ProductListRow n’a pas d’associations – au maximum vous irez chercher row.name(), et ce n’est qu’une chaîne, pas un déclencheur SQL.

Enfin, règle importante « ne vous tirez pas une balle dans le pied, même si le pied est joli » : la projection ne doit pas être utilisée pour écrire. N’essayez pas de « sauvegarder la liste » ou de « mettre à jour le statut de la commande via un DTO ». Le scénario d’écriture doit rester un scénario d’écriture : on charge une entité managée (ou on obtient une référence), on modifie, on valide. La projection est un contrat de lecture seule. Sa force réside justement dans le fait qu’elle ne prétend pas tout faire à la fois.

6. Vérification via la trace SQL

Il y a une illusion dangereuse : « j’ai remplacé List<Product> par List<ProductListRow>, donc j’ai rendu ça plus rapide ». Dans le monde d’Hibernate, ça ne marche pas comme ça. Nous avons déjà l’outil principal du cours : la trace SQL et les statistiques. Un bon refactoring de listes doit être vérifié visuellement : quelles requêtes sont parties vers la BDD, combien, quelle est leur largeur.

Avant le refactoring, la liste des commandes ressemblait très facilement à ça : une requête pour les commandes, puis une série de requêtes pour le client (si quelque part on lit l’email). Dans la trace SQL, on voit généralement des select ... from customer where id = ? identiques se répéter plusieurs fois. Et oui, le code peut avoir l’air innocent, surtout si ce n’est « que du logging » ou « juste un mapping en DTO ».

Après le passage à la projection sur les commandes, le tableau devient ennuyeux – et c’est un compliment. Vous verrez une seule requête qui contient directement un join sur le client, et plus aucune surprise concernant l’email. Pour la Page s’ajoutera un count, mais c’est attendu et contrôlable. Et le plus agréable : la largeur du select devient plus petite. Dans la liste iront exactement les colonnes que vous avez choisies. Pas les 20 champs de la commande, pas tout le client, seulement les 4‑5 valeurs nécessaires.

Si le profil sql-trace est activé, il est très utile de s’habituer à un petit rituel après ce genre de modifications : exécuter le scénario de la liste, regarder le SQL, vérifier qu’il n’y a qu’une requête (ou deux pour une Page), vérifier qu’il n’y a pas de select secondaires sur des associations lazy, vérifier que le select ne traîne pas de superflu. Cela prend une minute, mais fait gagner des heures à enquêter « pourquoi ça rame en prod alors que sur mon portable tout vole ».

7. Erreurs typiques

Erreur n°1 : refactoring « cosmétique » sans changer le type de résultat.
Quand vous passez des listes à la projection, l’erreur la plus fréquente est de faire un refactoring « cosmétique » : renommer la méthode en findProductRows..., mais laisser Page<Product>. Extérieurement ça a l’air plus joli, mais en réalité vous retournez toujours une managed-entity avec tous ses effets de bord. Vérifiez donc non pas le nom, mais le type de résultat et la forme du select.

Erreur n°2 : projection « universelle » pour tous les cas.
Une autre erreur courante est d’essayer de faire une projection « universelle » qui convienne aussi bien à la liste, à la fiche, à l’édition, et « au cas où » inclut en plus deux champs venant d’entités associées. Généralement, une telle projection grossit vite jusqu’à devenir une demi-entité, et on revient au même problème : un contrat trop large et trop de données à chaque lecture. La discipline ici est simple : un cas d’utilisation = un contrat de projection.

Erreur n°3 : ordre des arguments inversé dans select new ....
Souvent, des détails plus techniques se cassent aussi. Par exemple, pour une class-based projection, il est facile d’inverser l’ordre des arguments dans select new ... et dans le constructeur (ou le constructeur canonique d’un record). Le code compile, l’application démarre, puis dans la liste on se retrouve soudain avec sku dans le champ name. C’est une erreur désagréable car elle ne provoque pas toujours une exception – elle rend simplement les données incorrectes en silence. On la soigne par de l’attention et par l’habitude de garder le select et le constructeur côte à côte et de les lire de gauche à droite.

Erreur n°4 : tri instable dans les listes pageables.
Une douleur particulière est le tri instable dans les listes pageables. Vous triez les produits seulement par name, sans ajouter id, et à un moment l’utilisateur parcourt les pages et voit des lignes qui « sautent ». Ce n’est pas un bug d’Hibernate, c’est votre imprécision : pour des name identiques, l’ordre n’est pas défini. Donc, dans les listes, on a presque toujours besoin d’un critère de départage unique, le plus souvent id.

Erreur n°5 : « optimisation du count » sans comprendre que Page fait deux requêtes.
Enfin, beaucoup sont surpris que Page fasse deux requêtes et commencent à « optimiser le count » sans comprendre que le problème n’est généralement pas là. Dans un projet réel, le count peut effectivement devenir coûteux, mais d’abord il est plus important de comprendre qu’un scénario pageable implique deux requêtes, et de savoir les lire dans la trace SQL. Quand vous le comprenez, vous cessez d’avoir peur de la pagination de manière mystique et commencez à la concevoir en connaissance de cause.

1
Mission
Hibernate deep-dive, niveau 9, leçon 4
Bloqué
Refactorisation de la liste des produits en lecture basée sur projection
Refactorisation de la liste des produits en lecture basée sur projection
1
Mission
Hibernate deep-dive, niveau 9, leçon 4
Bloqué
Refactorisation de la liste des commandes pour la lecture par projection
Refactorisation de la liste des commandes pour la lecture par projection
1
Étude/Quiz
Projections de requêtes, niveau 9, leçon 4
Indisponible
Projections de requêtes
DTO et pagination
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION