1. Quand JPQL ne suffit plus face à la dynamique
JPQL est bon tant que la forme de la requête est stable et tient en une phrase. Mais dans une recherche backoffice typique, la clause where mène rarement une vie tranquille : aujourd'hui on a besoin d'un filtre par statut de commande, demain par email client, après-demain — par plage de dates. Et là, la chaîne de caractères cesse rapidement d'être pratique.
Il y a ce moment innocent dans la vie d'un développeur : d'abord on a un filtre, puis un deuxième, puis « et si on ajoutait une recherche par préfixe, mais seulement si l'utilisateur a saisi quelque chose », puis « et on pourrait aussi choisir un tri », et soudain on se retrouve avec 25 lignes de concaténation de chaînes, trois niveaux de if, et quelque part entre eux se cache un espace que vous avez oublié. Si vous avez de la chance — une exception sera levée. Si vous n'avez pas de chance — la requête fonctionnera, mais pas comme prévu, et vous passerez une heure à scruter le log SQL comme un horoscope.
Dans notre Commerce Persistence Lab, cela se voit très concrètement dans la recherche backoffice. Par exemple, nous voulons lire la liste des commandes selon différents paramètres optionnels : le statut de la commande peut être spécifié ou non, l'email du client peut être spécifié ou non, la plage de dates peut être spécifiée ou non. En JPQL, on peut écrire cela, mais quand la dynamique entre en jeu, soit vous faites une concaténation « magique » de chaînes, soit vous commencez à écrire une requête avec (:param is null or ...) pour chaque paramètre. Les deux approches fonctionnent, mais elles deviennent vite inconfortables : la première est fragile, la seconde transforme where en un tapis de conditions.
C'est exactement le type de requête où la forme du résultat peut rester étroite, mais c'est le where qui commence à changer.
Le critère pour savoir quand sortir Criteria API est simple : si le problème n'est pas que JPQL « ne sait pas faire », mais que la forme du where change constamment, alors Criteria devient plus apaisant pour l'esprit. Il ne rend pas le SQL meilleur automatiquement, mais il rend la construction de la requête moins fragile qu'« un assemblage de chaînes fait à la va-vite ».
Criteria API comme constructeur JPQL
Criteria API est souvent perçue comme un « bouton secret des professionnels » : on écrit en Criteria et la requête devient plus rapide. Hélas (ou heureusement), ce n'est pas comme ça que Criteria fonctionne. Criteria API est une façon objet de décrire la même requête que vous auriez pu écrire en chaîne JPQL. Ce n'est ni une « base de données différente » ni un « moteur différent ». Hibernate transformera toujours votre construction en SQL et l'enverra à PostgreSQL.
En revanche, Criteria offre autre chose : vous construisez la requête comme un arbre d'objets, non comme une chaîne. C'est particulièrement agréable quand les conditions sont optionnelles. Au lieu de « concaténer un morceau de texte, ne pas oublier l'espace, ne pas oublier le and », vous dites : « si le paramètre est défini, ajoute tel Predicate ». Psychologiquement, cela ressemble à la construction LEGO : vous ne dessinez pas une maison au feutre sur une serviette, vous assemblez des blocs, et le bloc superflu, vous ne le mettez simplement pas.
Il est important de bien comprendre la limite. Criteria n'annule pas la discipline de fetch, ne guérit pas le N+1, ne supprime pas la nécessité de regarder le trace SQL. Il offre simplement une manière plus stable de construire le where et le order by dynamiques en Java.
Quand ces conditions commencent à se répéter entre plusieurs cas de lecture, cette même mécanique est généralement emballée dans des filtres nommés côté Spring Data. Mais d'abord, il est utile de la voir sous sa forme brute.
2. Mini‑carte de Criteria API
Quand on voit du code Criteria pour la première fois, il ressemble à « Java écrit en Java ». L'ironie, c'est qu'il y a une certaine beauté là-dedans… mais pas au premier regard. Pour ne pas se noyer, il est utile de garder en tête une petite carte des objets : qui fait quoi et où il se trouve généralement dans le code. C'est comme dans une équipe : l'un planifie, l'autre construit, le troisième apporte les résultats — et personne n'est obligé de tout faire à la fois.
Voici un tableau pratique à voir comme un « dictionnaire des personnages » :
|
Où l'obtient‑on | Ce qu'il fait (en termes simples) |
|---|---|---|
| EntityManager | injecté dans la classe de requête | « Entrée dans le monde JPA/Hibernate » : crée la requête, l'exécute |
| CriteriaBuilder | entityManager.getCriteriaBuilder() | « Fabrique de conditions » : , , , , |
| CriteriaQuery<T> | cb.createQuery(T.class) | « Gabarit du résultat » : ce qu'on sélectionne et quel sera le type du résultat |
| Root<T> | cq.from(Entity.class) | « Racine de la requête » : point de départ pour |
| Join | root.join("association") | « Navigation par association » : pour filtrer sur les champs de l'entité liée |
| Predicate | cb.equal(...), |
Une condition dans le |
| TypedQuery<T> | entityManager.createQuery(cq) | « Requête prête à être exécutée » : on peut y mettre un limit/offset et l'exécuter |
Si vous gardez ce schéma en tête, Criteria cesse d'être une « incantation magique » et devient un simple pattern : d'abord on construit, ensuite on exécute. Dans les sections suivantes, nous allons assembler une telle requête dans le projet sur les entités réelles PurchaseOrder et Customer.
3. Squelette d'une requête Criteria
Il est préférable de commencer à découvrir Criteria non pas par la « dynamique à 12 filtres », mais par un modèle simple que vous pourrez ensuite étendre. En pratique, cela se passe ainsi : on crée CriteriaBuilder, on crée CriteriaQuery<T>, on définit Root<T>, on construit le where, on ajoute le order by, on exécute. Cela ressemble à la cuisine : d'abord la casserole, puis les ingrédients, puis « mener à cuisson », et seulement ensuite goûter.
Prenons un cas de lecture de base : « Donne les dernières commandes par date de création ». Aucune dynamique, juste le squelette.
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root;
// On suppose que EntityManager est déjà injecté (par exemple, dans la classe query)
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); // "fabrique" pour conditions/tris
CriteriaQuery<PurchaseOrder> cq = cb.createQuery(PurchaseOrder.class); // décrit le type du résultat
Root<PurchaseOrder> order = cq.from(PurchaseOrder.class); // racine de la requête (à partir de là on accède aux champs)
// On sélectionne l'entité entière et on définit le tri : les nouvelles commandes en premier
cq.select(order).orderBy(cb.desc(order.get("createdAt")));
Ici, deux choses sont importantes. Premièrement, nous n'écrivons pas la chaîne "select o from PurchaseOrder o ...", nous construisons un modèle objet de la requête. Deuxièmement, order.get("createdAt") est le chemin vers le champ de l'entité. Hibernate le traduira ensuite dans la colonne appropriée via le mapping.
Maintenant, exécutons la requête et limitons le résultat pour ne pas transformer notre base de données en « archive de tout et n'importe quoi » en un seul appel :
import jakarta.persistence.TypedQuery;
import java.util.List;
// Transforme CriteriaQuery en une requête exécutable
TypedQuery<PurchaseOrder> query = entityManager.createQuery(cq);
// Limite le résultat pour ne pas charger accidentellement "toute l'histoire sur 5 ans"
List<PurchaseOrder> orders = query.setMaxResults(20).getResultList();
Si vous activez le profil sql-trace, vous verrez un SQL classique select ... from purchase_order ... order by created_at desc limit 20. Autrement dit, Criteria est une façon de décrire la requête, pas un autre monde d'exécution.
4. Where dynamique et liste de Predicate
Passons maintenant à l'essentiel : la dynamique. Une requête backoffice standard a rarement un seul filtre fixe. Le plus souvent, c'est « si l'utilisateur a rempli le champ, on filtre ; sinon, on ne filtre pas ». En JPQL textuel, cela se transforme généralement en un constructeur conditionnel ou en un énorme where avec (:param is null or ...). Criteria permet de faire cela plus sereinement : on accumule simplement une liste de conditions, et on les applique dans le where à la fin.
Imaginons un filtre pour la recherche de commandes. Pour une telle recherche, il est pratique de séparer les paramètres d'entrée de la forme de sortie : le where vit sa propre vie, et le modèle de lecture peut être aussi bien une entité qu'un DTO.
import java.time.Instant;
// DTO du filtre : chaque champ peut être défini ou null (c'est-à-dire "on ne filtre pas dessus")
public record OrderSearchFilter(
String customerEmail,
OrderStatus status,
Instant createdFrom,
Instant createdTo
) {}
Maintenant, dans la classe de requête, on construit un Predicate uniquement pour les champs réellement définis :
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
// On met ici les conditions qu'il faut réellement appliquer (where dynamique)
List<Predicate> predicates = new ArrayList<>();
if (filter.status() != null) {
// Filtre par statut (égalité)
predicates.add(cb.equal(order.get("status"), filter.status()));
}
if (filter.createdFrom() != null) {
// Borne inférieure sur la date de création (incluse)
predicates.add(cb.greaterThanOrEqualTo(order.get("createdAt"), filter.createdFrom()));
}
if (filter.createdTo() != null) {
// Borne supérieure sur la date de création (généralement "inférieure à" pour que to fonctionne comme "avant le début du jour/de la seconde")
predicates.add(cb.lessThan(order.get("createdAt"), filter.createdTo()));
}
Le moment le plus « agréable » ici, c'est que vous ne pensez pas au and ni aux espaces. Vous pensez au sens : « il y a un paramètre — ajoute la condition ». Puis, en une seule fois, vous appliquez tout ce qui a été rassemblé :
import jakarta.persistence.criteria.Predicate;
// CriteriaQuery.where(...) accepte varargs Predicate et les relie par AND
Predicate[] where = predicates.toArray(new Predicate[0]);
// On applique le where dynamique + le tri (toujours au niveau du modèle, pas de chaîne SQL)
cq.select(order).where(where).orderBy(cb.desc(order.get("createdAt")));
Oui, where(where) semble un peu étrange (comme si Java se moquait de nous), mais l'idée est simple : le tableau de prédicats à l'intérieur de where est combiné par un AND logique. Pour la plupart des « formulaires de recherche », c'est exactement ce qu'il faut.
5. Join dans Criteria : filtre par email du client
Les filtres dynamiques portent souvent non seulement sur les champs de l'entité racine, mais aussi sur les associations. Par exemple, dans les commandes, nous voulons filtrer par Customer.email. En JPQL, cela s'écrit simplement : join o.customer c where c.email = :email. Dans Criteria, le principe est le même, seul le join est construit de manière objet.
D'abord, on obtient un Join de la commande vers le client. On le fait généralement une fois et on l'utilise ensuite dans les prédicats :
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
// Join est nécessaire pour filtrer sur les champs de l'entité liée (ce n'est pas un fetch !)
Join<PurchaseOrder, Customer> customer = order.join("customer", JoinType.INNER);
On peut alors ajouter la condition sur l'email, et elle devient également optionnelle :
if (filter.customerEmail() != null) {
// Filtre par email du client via le join
predicates.add(cb.equal(customer.get("email"), filter.customerEmail()));
}
Notez un détail important : nous travaillons toujours au niveau des champs de l'entité, pas des colonnes de tables. Cela reste la frontière ORM : Criteria aide à construire la requête, mais vous pensez toujours en modèle.
Il y a un point pratique utile à retenir dans le contexte du cours. Un tel join ne signifie pas en soi « nous avons chargé le client à l'intérieur de la commande ». Il signifie « nous avons utilisé l'association pour filtrer ». Pour le plan de fetch, Criteria a des mécanismes séparés (fetch, join fetch), mais nous ne les développons pas ici, car cela relève déjà du controlled fetching vu les jours précédents. Notre objectif actuel est de construire honnêtement le where, sans transformer accidentellement la requête en monstre avec un result set gonflé.
6. Criteria et projection DTO
Après la journée sur les projections, on a envie de les utiliser partout (et c'est normal : cela semble enfin raisonnable). Criteria sait aussi le faire. Et il est important de ne pas confondre deux modes de pensée : si vous sélectionnez une entité, le résultat sera managé, entrera dans le persistence context et pourra participer au dirty checking. Si vous sélectionnez une projection DTO, vous obtenez un objet ordinaire sans « magie de gestion ».
Imaginons que pour la liste des commandes dans l'admin, on ait besoin d'une « ligne » avec : l'id de la commande, le numéro, l'email du client, le statut. Créons un modèle DTO simple (record) :
// DTO "ligne de tableau" pour le backoffice : seulement ce qui est réellement nécessaire pour la liste
public record OrderRow(
Long id,
String orderNumber,
String customerEmail,
OrderStatus status
) {}
Maintenant, dans Criteria, on peut construire le DTO via cb.construct(...) :
import jakarta.persistence.criteria.CriteriaQuery;
// Ici, le résultat de la requête n'est pas une entité, mais un DTO (record) — sans la "magie" du persistence context
CriteriaQuery<OrderRow> cq = cb.createQuery(OrderRow.class);
Root<PurchaseOrder> order = cq.from(PurchaseOrder.class); // source principale de données
Join<PurchaseOrder, Customer> customer = order.join("customer"); // besoin de l'email du client
// On construit le DTO directement dans le select (équivalent JPQL : select new ...)
cq.select(cb.construct(
OrderRow.class,
order.get("id"),
order.get("orderNumber"),
customer.get("email"),
order.get("status")
));
Cela semble plus long que JPQL select new ..., mais l'avantage est dans la dynamique : par la suite, on ajoute exactement de la même manière des Predicate, un orderBy, une pagination. Et on obtient un résultat qui s'inscrit parfaitement dans l'approche read-model du jour précédent : la liste ne traîne pas un graphe d'entités superflu et ne crée pas d'effets secondaires indésirables.
7. Tri et pagination dans Criteria
Quand les filtres sont dynamiques, le tri l'est généralement aussi : l'utilisateur veut trier « par date », « par statut », « par numéro de commande ». Dans Criteria, le tri fait partie de CriteriaQuery, tandis que la limite et l'offset font partie de l'exécution (TypedQuery). Cela sépare bien les responsabilités : CriteriaQuery décrit ce qu'il faut lire, et TypedQuery décrit comment l'exécuter dans le cadre d'un appel spécifique.
Le tri se définit via orderBy et cb.asc/cb.desc :
// Le tri fait partie de CriteriaQuery (c'est-à-dire la "forme" de la requête)
cq.orderBy(cb.desc(order.get("createdAt"))); // les plus récents en premier
Et la pagination se définit sur TypedQuery :
import jakarta.persistence.TypedQuery;
// La pagination fait partie de l'exécution de la requête (pas de CriteriaQuery)
TypedQuery<OrderRow> q = entityManager.createQuery(cq);
q.setFirstResult(offset); // à partir de quelle ligne (offset)
q.setMaxResults(limit); // combien de lignes (limit)
Il est très important que Criteria ne vous dispense pas de la discipline d'observabilité SQL. Quand vous changez le tri et les filtres, le SQL change. Et si vous choisissez un tri sur un champ sans index (ou sur une expression calculée), PostgreSQL vous dira honnêtement : « OK, je vais trier, mais ne sois pas surpris après ». Dans le cadre du cours, nous n'entrons pas dans l'optimisation des plans, mais une habitude demeure : activez sql-trace, regardez la forme du SQL, comparez avec les attentes.
8. Placement du code Criteria dans le projet
L'erreur architecturale la plus fréquente avec Criteria est de le mettre directement dans le service, à côté de la logique métier. Au final, le service devient à la fois « métier, constructeur SQL, tri et pagination ». Au bout de quelques semaines, une telle méthode ressemble à une série « Les Experts Criteria » où il y a un nouveau if dans chaque épisode. Il est bien plus serein de garder Criteria dans une classe de requête séparée, dans le package ...query, comme le propose la structure du Commerce Persistence Lab.
Par exemple, pour les commandes, cela pourrait être quelque chose comme com.example.commerce.orders.query.OrderSearchQuery. Le service dit : « Trouve les commandes selon le filtre », et la classe de requête assemble Criteria en interne et retourne un DTO ou une liste d'entités.
Le squelette d'une telle classe est généralement très court et honnête : un constructeur avec EntityManager, une méthode de recherche qui contient trois phases — construire la query, appliquer les prédicats, exécuter.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
@Repository // On marque le composant comme read/query-repository (pas nécessairement Spring Data)
public class OrderSearchQuery {
private final EntityManager entityManager;
public OrderSearchQuery(EntityManager entityManager) {
// EntityManager vient du contexte JPA (injection de dépendances)
this.entityManager = entityManager;
}
}
Un tel placement fait deux choses. Premièrement, le service reste lisible et vous ne mélangez pas le code métier avec la construction de la forme SQL. Deuxièmement, le code Criteria devient plus facile à tester en tant que composant de lecture : vous pouvez écrire des tests d'intégration sur une requête spécifique, regarder le SQL et vérifier que la dynamique fonctionne bien comme vous l'avez conçu.
9. Erreurs typiques lors du travail avec Criteria API
Erreur n°1 : réécrire absolument tout en Criteria, même les requêtes fixes simples.
Criteria est utile là où la forme du where est réellement dynamique. Si vous avez une requête « produits actifs par statut, tri par nom » et qu'elle ne change jamais, le JPQL textuel est généralement plus simple à lire et à maintenir. La règle « le plus simple qui reste transparent » l'emporte souvent : il faut sortir Criteria quand il soulage une vraie douleur, pas parce que « c'est plus classe ».
Erreur n°2 : mélanger la logique métier et la construction de requêtes dans une même méthode géante du service.
Quand, dans le service, cohabitent « changer le statut de la commande », « vérifier les règles de transition de statut » et « construire un critère avec 8 filtres », vous obtenez un code où la complexité ne s'additionne pas, mais se multiplie. Vous ne voyez plus où se forme le modèle de lecture et quelles données sont réellement nécessaires. Il vaut mieux garder Criteria dans une classe de requête dans ...query et laisser au service l'orchestration du scénario.
Erreur n°3 : utiliser root.get("fieldName") sans discipline de nommage et obtenir une erreur au runtime.
Dans Criteria, les champs sont souvent indiqués par des chaînes. Une faute de frappe comme "createdAt" → "createAt" ne sera pas attrapée par le compilateur et vous le découvrirez au pire moment — au runtime (généralement pendant une démo). Une simple discipline sauve la mise : des noms de champs soignés, un refactoring via l'IDE et un minimum de « chaînes magiques ». Oui, JPA a un metamodel pour la sécurité de typage, mais nous ne transformons pas la leçon en un cours séparé sur la génération de metamodel.
Erreur n°4 : s'attendre à ce que Criteria « améliore automatiquement les performances » et cesser de regarder le SQL.
Criteria n'est pas un optimiseur. Il peut même rendre la requête moins évidente pour le développeur si vous perdez la compréhension de ce que vous construisez. Dans un cours deep‑dive, c'est particulièrement dangereux : vous devez être capable d'ouvrir le trace SQL et de dire « voilà, ce join, ces conditions, cet order by ». Par conséquent, après toute modification notable du code Criteria, une habitude unique : activez sql-trace, regardez le SQL généré, assurez-vous que la forme de la requête correspond à l'idée.
Erreur n°5 : retourner une entité managée là où une liste en lecture suffit, puis s'étonner du « travail supplémentaire d'Hibernate ».
Si Criteria retourne PurchaseOrder en tant qu'entité, ces objets entreront dans le persistence context et seront managés. Dans les scénarios de lecture, cela peut être superflu : vous obtenez un overhead potentiel sur le dirty checking et des risques d'initialisations lazy inutiles si quelqu'un « logge juste la commande entière ». Pour les tableaux de backoffice, une projection (OrderRow) est souvent plus pratique — et Criteria sait le faire via cb.construct(...), comme nous l'avons montré aujourd'hui.
GO TO FULL VERSION