🎯 OBJECTIF
Comprendre comment :
PredicateSpecification, Specification, UpdateSpecification, DeleteSpecificationand, or, not, allOf, anyOf et unrestricted(), sans jamais manipuler de nullfindAll, count, exists, delete, update et la fluent API findByexists plutôt qu'avec un join, et réutiliser une jointure entre specs composéescount dérivée : fetch, distinct, orderBy@Query, Specification, Query by Example et Criteria manuelle🔖 Version de l'outil
Version au moment de la rédaction : Spring Data JPA 4.1.1 (Spring Boot 4, Hibernate 7). Dernière version relue : 4.1.1, le 25 septembre 2026.
🧠 MODÈLE MENTAL
Imaginez un écran de recherche de commandes. Il a dix filtres : statut, client, dates, référence, produit. Chaque filtre est optionnel. L'utilisateur peut en remplir un, trois, ou aucun.
Avec les méthodes dérivées de Spring Data, il faut une méthode par combinaison. findByStatus. findByStatusAndCustomer. findByStatusAndCustomerAndCreatedAtAfter. Avec dix filtres, le nombre de combinaisons se compte en centaines. Le repository devient illisible. Personne ne sait plus quelle méthode est encore utilisée.
JPA propose la Criteria API. Avec elle, on construit la requête en code Java, au moment où on en a besoin. Le problème : ce code est long. Une seule méthode finit par contenir le SELECT, les jointures, les filtres et le tri. Rien n'est réutilisable.
Une Specification règle ça en ne gardant qu'une seule partie : le WHERE. Une Specification, c'est un morceau de filtre. Rien de plus. hasStatus(PAID) est un morceau. createdAfter(date) en est un autre. On les assemble avec and et or, comme des booléens. Le repository reçoit le filtre assemblé et fait le reste : il écrit le SELECT, applique la pagination et le tri, exécute la requête.
L'idée à retenir : une Specification est une fonction qui renvoie un Predicate. Tout ce qui n'est pas un filtre (les colonnes lues, les agrégats, le GROUP BY) est hors de son périmètre. C'est ce qui la rend simple à assembler. C'est aussi ce qui fixe ses limites.
CriteriaBuilder — fabrique de prédicats et d'expressions (equal, greaterThan, like, and, or, exists).CriteriaQuery — la requête SELECT en construction : type de résultat, distinct, orderBy, groupBy.Root / From — point d'entrée sur une entité. Root est la table principale. From est le type commun à Root et à une jointure.Predicate — une condition booléenne. Le WHERE d'une requête est un Predicate unique, souvent composé.JpaSpecificationExecutor — interface à ajouter au repository pour accepter des Specifications.Order_, Customer_ générées à la compilation. Elles remplacent les chaînes "status" par des références typées.findBy(spec, q -> ...). Elle permet de trier, limiter, projeter et choisir le type de résultat.Page.Voici le chemin complet. On part du filtre saisi par l'utilisateur. On arrive au SQL exécuté.
flowchart LR
A["Critères de recherche<br/>(status, customerId, dates...)"] --> B["OrderSpecs<br/>hasStatus(), forCustomer(), createdBetween()"]
B --> C["Composition<br/>allOf(...) / and / or"]
C --> D["JpaSpecificationExecutor<br/>findAll(spec, pageable)"]
D --> E["CriteriaQuery<br/>SELECT o FROM Order o WHERE (spec)"]
D --> F["CriteriaQuery<br/>SELECT count(o) FROM Order o WHERE (spec)"]
E --> G[("SQL")]
F --> GmermaidLe contrat est très petit. Une spec reçoit trois objets. Elle renvoie un Predicate.
public interface Specification<T> extends Serializable {
@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}javaUn exemple minimal. Deux specs sur l'entité Order :
public class OrderSpecs {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get(Order_.status), status); // ✓ métamodèle typé
}
public static Specification<Order> createdAfter(LocalDateTime date) {
return (root, query, cb) -> cb.greaterThan(root.get(Order_.createdAt), date);
}
}javaLe repository. Il suffit d'ajouter JpaSpecificationExecutor :
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
}javaL'appel. On assemble deux specs avec and, puis on passe le résultat au repository :
List<Order> orders = orderRepository.findAll(
hasStatus(OrderStatus.PAID).and(createdAfter(LocalDateTime.now().minusDays(7)))
);javaCe que fait Spring, dans l'ordre :
CriteriaQuery qui sélectionne des Order.toPredicate sur la spec assemblée.Predicate obtenu dans le WHERE.Le SQL produit :
select o.id, o.status, o.created_at, o.customer_id, ... -- toutes les colonnes de l'entité
from orders o
where o.status = ? and o.created_at > ?sql🔑 Conclusion clé
Une Specification décide du WHERE, et seulement du WHERE. Le SELECT, le FROM, la pagination et le tri sont décidés par JpaSpecificationExecutor. C'est une division du travail volontaire. La spec reste petite, donc réutilisable, donc facile à assembler.
Spring Data JPA 4.0 a réorganisé les Specifications en quatre interfaces. Toutes ont une méthode toPredicate. Ce qui change, ce sont les paramètres reçus.
| Interface | Signature de toPredicate |
Usage |
|---|---|---|
PredicateSpecification<T> |
(From<?, T> from, CriteriaBuilder cb) |
Prédicat pur, sans accès à la requête. Réutilisable dans un SELECT, un UPDATE, un DELETE, ou sur une jointure |
Specification<T> |
(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) |
Prédicat pour un SELECT. Accès à la requête pour distinct, fetch, sous-requête |
UpdateSpecification<T> |
(Root<T> root, CriteriaUpdate<T> update, CriteriaBuilder cb) |
Prédicat pour un UPDATE en masse |
DeleteSpecification<T> |
(Root<T> root, CriteriaDelete<T> delete, CriteriaBuilder cb) |
Prédicat pour un DELETE en masse |
PredicateSpecification : le bloc de baseElle ne reçoit pas la requête. Elle ne peut donc pas faire de fetch. Elle ne peut pas non plus poser un distinct. En échange, elle marche partout : dans un SELECT, un UPDATE, un DELETE.
Elle reçoit un From, pas un Root. Pourquoi c'est important : un Root est la table principale. Un From est plus général, c'est la table principale ou une jointure. La même spec fonctionne donc sur les deux.
public class OrderSpecs {
public static PredicateSpecification<Order> hasStatus(OrderStatus status) {
return (from, cb) -> cb.equal(from.get(Order_.status), status);
}
public static PredicateSpecification<Order> forCustomer(Long customerId) {
return (from, cb) -> cb.equal(from.get(Order_.customer).get(Customer_.id), customerId);
}
}javaPour convertir une PredicateSpecification :
Specification : Specification.where(predicateSpec)DeleteSpecification : DeleteSpecification.where(predicateSpec)Souvent ce n'est même pas nécessaire. Les méthodes de JpaSpecificationExecutor acceptent directement une PredicateSpecification.
UpdateSpecification et DeleteSpecification : les écritures en masseUne UpdateSpecification a deux parties. La partie SET dit quoi modifier. La partie WHERE dit sur quelles lignes. La partie WHERE est une PredicateSpecification, donc les mêmes specs que pour la lecture.
public static UpdateSpecification<Order> archiveOlderThan(LocalDateTime limit) {
return UpdateSpecification.<Order>update((root, update, cb) -> {
update.set(Order_.status, OrderStatus.ARCHIVED); // ✓ la partie SET
})
.where(createdBefore(limit).and(hasStatus(OrderStatus.DELIVERED))); // ✓ la partie WHERE, en PredicateSpecification
}
// Exécution
long updated = orderRepository.update(archiveOlderThan(LocalDateTime.now().minusYears(2)));
long deleted = orderRepository.delete(hasStatus(OrderStatus.CANCELLED));java⚡ Les écritures en masse ne passent pas par le contexte de persistance
update(spec) et delete(spec) envoient un UPDATE ou un DELETE SQL directement à la base. Hibernate n'est pas au courant. Conséquences :
@PreUpdate et @PreRemove ne sont pas appelés.Deux parades : exécuter ces opérations dans une transaction dédiée, ou appeler entityManager.clear() juste après.
Toutes les interfaces ont les mêmes combinateurs :
Specification<Order> spec = Specification.allOf( // ✓ AND de toutes
hasStatus(status),
forCustomer(customerId),
createdBetween(from, to)
);
Specification<Order> other = Specification.anyOf(a, b); // ✓ OR de toutes
Specification<Order> negated = Specification.not(a); // ✓ NOT
Specification<Order> chained = a.and(b).or(c); // ✓ chaînagejavaallOf et anyOf acceptent une liste vide. Dans ce cas, le résultat est unrestricted(). Il matche tout.
unrestricted() : le remplaçant des specs nullesAvant la 3.5, un filtre optionnel se codait souvent avec null. On écrivait Specification.where(null).and(...). Spring acceptait la spec nulle et l'ignorait.
Ce n'est plus possible. Specification.where(spec) a été dépréciée en 3.5. Elle a été retirée en 4.0. L'équipe Spring ne veut plus supporter les specs nulles.
Le remplaçant est unrestricted(). C'est une spec qui renvoie un prédicat null. Dans une composition, elle est ignorée :
unrestricted().and(other) // ne considère que other
unrestricted().or(other) // ne considère que other
not(unrestricted()) // équivaut à unrestricted()javaPour un filtre optionnel, le pattern devient :
public static Specification<Order> hasStatusIfPresent(@Nullable OrderStatus status) {
return status == null
? Specification.unrestricted() // ✓ pas de null
: (root, query, cb) -> cb.equal(root.get(Order_.status), status);
}java⚡ La sémantique de not(unrestricted()) a bougé
Jusqu'en 3.5.4, not(unrestricted()) donnait un prédicat qui ne matchait rien. Depuis 3.5.5, et en 4.x, il équivaut à unrestricted(). Il matche donc tout.
Le sens s'est inversé. Du code écrit avec l'ancienne version change de résultat après migration, sans erreur. Il faut relire chaque not(...) posé sur une spec qui peut être vide.
Une méthode statique par règle métier. Le nom de la méthode est le nom de la règle. Les specs deviennent un vocabulaire que tout le monde lit.
public final class OrderSpecs {
private OrderSpecs() {}
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get(Order_.status), status);
}
public static Specification<Order> forCustomer(Long customerId) {
return (root, query, cb) -> cb.equal(root.get(Order_.customer).get(Customer_.id), customerId);
}
public static Specification<Order> createdBetween(LocalDateTime from, LocalDateTime to) {
return (root, query, cb) -> cb.between(root.get(Order_.createdAt), from, to);
}
public static Specification<Order> referenceContains(String fragment) {
return (root, query, cb) ->
cb.like(cb.lower(root.get(Order_.reference)), "%" + fragment.toLowerCase() + "%");
}
public static Specification<Order> containsSku(String sku) {
return (root, query, cb) -> {
Subquery<Long> sub = query.subquery(Long.class); // ✓ exists, pas de join
Root<OrderLine> line = sub.from(OrderLine.class);
sub.select(cb.literal(1L))
.where(cb.equal(line.get(OrderLine_.order), root),
cb.equal(line.get(OrderLine_.sku), sku));
return cb.exists(sub);
};
}
}javaLe service reçoit un record de critères. Tous les champs sont optionnels. Pour chaque champ, la règle est simple : s'il est rempli, on ajoute la spec ; sinon, on met unrestricted(). Il n'y a aucun null dans l'assemblage.
public record OrderSearchCriteria(
@Nullable OrderStatus status,
@Nullable Long customerId,
@Nullable LocalDateTime from,
@Nullable LocalDateTime to,
@Nullable String reference,
@Nullable String sku
) {}
@Service
public class OrderSearchService {
private final OrderRepository orderRepository;
public Page<Order> search(OrderSearchCriteria c, Pageable pageable) {
Specification<Order> spec = Specification.allOf(
c.status() != null ? hasStatus(c.status()) : unrestricted(),
c.customerId() != null ? forCustomer(c.customerId()) : unrestricted(),
c.from() != null && c.to() != null ? createdBetween(c.from(), c.to()) : unrestricted(),
c.reference() != null ? referenceContains(c.reference()) : unrestricted(),
c.sku() != null ? containsSku(c.sku()) : unrestricted()
);
return orderRepository.findAll(spec, pageable);
}
}javaSans métamodèle, on écrit root.get("status"). C'est une chaîne. Si on écrit "statut" par erreur, le code compile. L'erreur ne sort qu'à l'exécution, et seulement quand ce filtre est activé.
Avec le métamodèle, on écrit root.get(Order_.status). Order_ est une classe générée. Si le champ n'existe pas, javac refuse de compiler.
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId> <!-- ✓ Hibernate 7 ; hibernate-jpamodelgen en Hibernate 6 -->
<version>${hibernate.version}</version>
</path>
</annotationProcessorPaths>xml🔑 Règle
Une spec par règle métier, nommée comme la règle. Le métamodèle partout. unrestricted() pour les filtres absents. exists pour les collections. Le repository ne contient jamais de logique d'assemblage : c'est le rôle du service.
JpaSpecificationExecutor propose deux familles de méthodes.
Optional<Order> one = orderRepository.findOne(spec); // IncorrectResultSizeDataAccessException si > 1
List<Order> all = orderRepository.findAll(spec);
List<Order> sorted = orderRepository.findAll(spec, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(spec, PageRequest.of(0, 20));
long count = orderRepository.count(spec);
boolean exists = orderRepository.exists(spec);
long deleted = orderRepository.delete(spec); // depuis 3.0
long updated = orderRepository.update(updateSpec); // depuis 4.0javafindBy(spec, q -> ...)Les méthodes directes ont des limites. Elles renvoient toujours des entités. Elles ne savent pas limiter à N résultats. Elles ne savent pas parcourir un gros volume par paquets.
La fluent API lève ces limites. On passe la spec, puis une fonction qui décrit ce qu'on veut : projection, limite, tri, scroll, stream.
// Page projetée, triée
Page<OrderSummary> page = orderRepository.findBy(spec,
q -> q.as(OrderSummary.class)
.page(PageRequest.of(0, 20, Sort.by("createdAt").descending())));
// Le plus récent
Optional<Order> latest = orderRepository.findBy(spec,
q -> q.sortBy(Sort.by("createdAt").descending()).first());
// Stream pour un export (à fermer)
try (Stream<Order> stream = orderRepository.findBy(spec, FluentQuery.FetchableFluentQuery::stream)) {
stream.forEach(exporter::write);
}
// Keyset scrolling
Window<Order> window = orderRepository.findBy(spec,
q -> q.sortBy(Sort.by("id")).limit(100).scroll(ScrollPosition.keyset()));java| Méthode | Type | Effet |
|---|---|---|
sortBy(Sort) |
intermédiaire | Ajoute un tri. Répétable. Un page(Pageable) trié écrase tout |
limit(int) |
intermédiaire | Limite le nombre de résultats |
as(Class) |
intermédiaire | Projette vers une interface ou un record |
project(String...) |
intermédiaire | Limite les propriétés lues |
first(), one(), all() |
terminal | Un ou plusieurs résultats |
page(Pageable), slice(Pageable) |
terminal | Avec ou sans requête de count |
scroll(ScrollPosition) |
terminal | Offset ou keyset |
stream() |
terminal | Flux à fermer |
count(), exists() |
terminal | Agrégat simple |
🔑 Conclusion clé
Pour un écran de liste qui affiche des entités, findAll(spec, pageable) suffit. Dès qu'il faut projeter, limiter ou parcourir un gros volume, passer à findBy. Si le total n'est pas affiché, préférer slice à page : cela évite la requête de count.
C'est ici que se cachent la plupart des bugs de Specification.
join vs fetchIl y a deux façons de joindre une table dans une spec. Elles ne servent pas au même besoin.
root.join("lines") sert à filtrer sur les lignes. Les lignes ne sont pas chargées.root.fetch("lines") sert à charger les lignes en même temps que les commandes.// Filtrer sur les lignes : join
(root, query, cb) -> cb.equal(root.join(Order_.lines).get(OrderLine_.sku), sku)
// Charger les lignes avec les commandes : fetch
(root, query, cb) -> {
root.fetch(Order_.lines, JoinType.LEFT);
return cb.conjunction();
}javaPrenons une commande avec trois lignes qui ont toutes le SKU cherché. Avec un join, la commande apparaît trois fois dans le résultat. Une fois par ligne.
Le réflexe est d'ajouter query.distinct(true). Ça corrige la liste. Mais ça a deux effets de bord :
exists : la bonne réponse pour filtrer sur une collectionÀ la place du join, on écrit une sous-requête. Elle dit : "il existe au moins une ligne de cette commande avec ce SKU".
public static Specification<Order> containsSku(String sku) {
return (root, query, cb) -> {
Subquery<Long> sub = query.subquery(Long.class);
Root<OrderLine> line = sub.from(OrderLine.class);
sub.select(cb.literal(1L))
.where(cb.equal(line.get(OrderLine_.order), root), // ✓ corrélation avec la commande
cb.equal(line.get(OrderLine_.sku), sku));
return cb.exists(sub);
};
}javaSQL produit :
select o.* from orders o
where exists (select 1 from order_line l where l.order_id = o.id and l.sku = ?)sqlCe qu'on gagne :
distinct.containsSku("A").and(containsSku("B")) donne deux exists indépendants. Cela veut dire : la commande contient A, et la commande contient B. C'est bien ce qu'on voulait.Prenons deux specs. La première fait root.join(lines) et filtre sur le SKU. La seconde fait aussi root.join(lines) et filtre sur la quantité. On les combine avec and.
Chaque spec ne connaît pas l'autre. Chacune crée sa propre jointure. Le SQL contient donc order_line deux fois : l1 et l2.
Conséquence : la condition sur le SKU regarde l1. La condition sur la quantité regarde l2. Rien n'oblige l1 et l2 à être la même ligne. Une commande avec une ligne "SKU A, quantité 1" et une autre ligne "SKU B, quantité 50" est retournée. Ce n'est pas ce qu'on voulait. En plus, la requête est plus lente, car les lignes sont combinées entre elles (produit cartésien).
Si le besoin est bien "une même ligne qui satisfait les deux conditions", il y a deux solutions :
static <X, Y> Join<X, Y> joinOnce(From<?, X> from, SingularAttribute<? super X, Y> attr) {
return joinOnce(from, attr.getName());
}
@SuppressWarnings("unchecked")
static <X, Y> Join<X, Y> joinOnce(From<?, X> from, String attribute) {
return (Join<X, Y>) from.getJoins().stream()
.filter(j -> j.getAttribute().getName().equals(attribute))
.findFirst()
.orElseGet(() -> from.join(attribute)); // ✓ une seule jointure par attribut
}java⚡ Deux join sur la même collection dans deux specs composées
C'est le bug silencieux le plus fréquent. Le résultat est faux ou trop large. La requête est toujours plus lente.
Règle : exists pour filtrer sur une collection. join seulement dans une spec unique qui pose toutes ses conditions elle-même, ou avec un helper de réutilisation.
Une Page a besoin de deux choses : les lignes de la page, et le nombre total de lignes. Spring fait donc deux requêtes. La première lit les données. La seconde fait un count.
Le point important : votre spec est appelée deux fois. Une fois pour la requête de données. Une fois pour la requête de count. C'est la même lambda. Mais la requête reçue en paramètre n'est pas la même. Dans le second cas, query renvoie un Long.
sequenceDiagram
participant S as Service
participant R as SimpleJpaRepository
participant Spec as Specification
participant DB as PostgreSQL
S->>R: findAll(spec, pageable)
R->>Spec: toPredicate(root, CriteriaQuery<Order>, cb)
Spec-->>R: predicate
R->>DB: select o.* ... where (predicate) order by ... limit 20 offset 0
DB-->>R: 20 lignes
R->>Spec: toPredicate(root, CriteriaQuery<Long>, cb)
Note over Spec: même lambda, requête différente
Spec-->>R: predicate
R->>DB: select count(o) ... where (predicate)
DB-->>R: total
R-->>S: Page<Order>mermaidTout ce que vous faites dans la spec s'applique donc aussi au count. C'est la source des trois pièges qui suivent.
Piège 1 : un fetch fait échouer le count. Un fetch charge une collection. Sur une requête count, ça n'a pas de sens. Hibernate refuse avec QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list.
La parade : tester le type de résultat. Si c'est Long, on est sur le count, et on ne fait pas le fetch.
public static Specification<Order> withLines() {
return (root, query, cb) -> {
if (query.getResultType() != Long.class && query.getResultType() != long.class) { // ✓ pas sur le count
root.fetch(Order_.lines, JoinType.LEFT);
}
return cb.conjunction();
};
}javaPiège 2 : distinct change le count. query.distinct(true) donne select count(distinct o). Le résultat est juste. Mais la requête est plus lente. Et avec exists, ce distinct est inutile.
Piège 3 : un orderBy dans la spec est écrasé. Si le Pageable contient un tri, Spring appelle query.orderBy(...) après la spec. Ce tri remplace celui de la spec. Le tri appartient au Pageable ou au Sort, pas à la spec.
⚡ fetch + pagination = pagination en mémoire
Même sur la requête de données, un fetch de collection combiné à un Pageable pose problème. Hibernate affiche HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. Il charge toutes les lignes, puis découpe en Java.
Ce n'est pas propre aux Specifications. Un @Query avec join fetch et un Pageable fait pareil. Voir jpa-hibernate-pieges-production et jpa-hibernate-gerer-le-n-1-quand-n-est-grand.
Par défaut, findAll(spec) renvoie des entités complètes. Prenons une liste qui affiche deux colonnes. L'entité en a trente. On lit trente colonnes pour en montrer deux. C'est du gaspillage : plus de données transférées, plus de mémoire, et un dirty checking inutile.
On déclare une interface avec seulement les champs voulus. On la passe à as(...).
public interface OrderSummary {
Long getId();
String getReference();
OrderStatus getStatus();
}
Page<OrderSummary> page = orderRepository.findBy(spec,
q -> q.as(OrderSummary.class).page(pageable));javaDepuis Spring Data JPA 3.5, cette projection ne lit que les propriétés de l'interface. Avant, elle chargeait l'entité entière puis la convertissait.
Une limite reste. Une propriété imbriquée, comme getCustomer().getName(), déclenche la jointure et charge le sous-objet complet.
Parfois il faut choisir les colonnes exactement. Par exemple une colonne d'une table jointe. Ou un agrégat. Dans ce cas, on sort du repository.
La spec reste utile. On l'appelle soi-même pour obtenir le WHERE.
@Repository
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderSummaryDto> search(Specification<Order> spec) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<OrderSummaryDto> q = cb.createQuery(OrderSummaryDto.class);
Root<Order> root = q.from(Order.class);
q.select(cb.construct(OrderSummaryDto.class,
root.get(Order_.id),
root.get(Order_.reference),
root.get(Order_.customer).get(Customer_.name))) // ✓ colonne d'une jointure
.where(spec.toPredicate(root, q, cb)); // ✓ réutilise la spec
return em.createQuery(q).getResultList();
}
}
public record OrderSummaryDto(Long id, String reference, String customerName) {}javaLe SQL ne lit que trois colonnes. Aucune entité n'entre dans le contexte de persistance.
🔑 Règle
Pour une liste ou un export, projeter. findBy(spec, q -> q.as(...)) quand les champs sont au premier niveau. Criteria manuelle avec spec.toPredicate(...) dès qu'il faut une colonne de jointure ou un agrégat. Et @Transactional(readOnly = true) sur toute lecture qui garde des entités.
Beaucoup d'articles reprochent aux Specifications des défauts qui viennent en réalité de JPA ou d'Hibernate. Il faut séparer les deux.
WHERE. Le SELECT, le GROUP BY, les agrégats et le HAVING ne sont pas dans son périmètre. Pour une somme par client, il faut repasser par EntityManager.@Query et select new Dto(...), on choisit tout.fetch, distinct et orderBy de la section 7. Avec @Query, on écrit countQuery soi-même.@Query est validé au démarrage de l'application. Une spec avec un chemin faux ne casse que quand ce filtre est activé. Le métamodèle corrige ça pour les noms de propriétés. Il ne corrige pas la logique.jsonb ou ltree : la Criteria API standard ne les connaît pas. Il faut caster en HibernateCriteriaBuilder, ou passer par cb.function(...). C'est verbeux et couplé à Hibernate.where(spec) retirée en 4.0. unrestricted() ajoutée en 3.5.2. not(unrestricted()) corrigée en 3.5.5. Du code 3.x peut changer de comportement.| Comportement | Origine | Même chose avec |
|---|---|---|
| Chargement de toutes les colonnes de l'entité | JPA | findById, méthode dérivée sans projection |
| N+1 sur les relations lazy | Hibernate | Toute requête qui renvoie des entités. Se règle avec default_batch_fetch_size |
fetch + pagination en mémoire (HHH90003004) |
Hibernate | @Query avec join fetch et un Pageable |
| Besoin d'une base pour tester sérieusement | JPA | @Query aussi. La différence est le point 5 : validation au démarrage ou pas |
🔑 Conclusion clé
Les Specifications sont bonnes pour un seul cas : des filtres optionnels combinables sur une entité, sans agrégat. Les autres reproches qu'on leur fait viennent de JPA ou d'Hibernate. On les retrouve avec @Query.
Le pattern tentant : construire une spec depuis des paramètres HTTP libres, par exemple ?field=status&op=eq&value=PAID.
// ❌ n'importe quelle colonne de l'entité devient filtrable
public static <T> Specification<T> generic(String field, String value) {
return (root, query, cb) -> cb.equal(root.get(field), value);
}javaCe n'est pas une injection SQL. Les valeurs passent en paramètres. La base est protégée. Le problème est ailleurs : le nom du champ vient de l'appelant. Il peut donc mettre n'importe quel nom de colonne de l'entité.
Exemple. L'entité a un champ internalMargin que l'API n'expose jamais. L'appelant envoie ?field=internalMargin&op=gt&value=30. Il reçoit la liste des commandes dont la marge dépasse 30. Il n'a jamais vu la colonne, mais il vient de lire son contenu. En répétant avec d'autres valeurs, il retrouve la marge exacte de chaque commande. Même chose avec passwordHash ou customer.email. Et un chemin comme a.b.c peut déclencher des jointures que personne n'a prévues.
La parade : une liste blanche. On écrit soi-même les chemins autorisés. L'appelant ne fournit qu'une clé.
private static final Map<String, Function<Root<Order>, Path<?>>> FILTERABLE = Map.of(
"status", root -> root.get(Order_.status),
"reference", root -> root.get(Order_.reference),
"customer", root -> root.get(Order_.customer).get(Customer_.id)
);
public static Specification<Order> filter(String field, Object value) {
Function<Root<Order>, Path<?>> path = FILTERABLE.get(field);
if (path == null) {
throw new IllegalArgumentException("Filtre non autorisé : " + field); // ✓ refus explicite
}
return (root, query, cb) -> cb.equal(path.apply(root), value);
}java⚡ Une spec construite depuis des noms de champs fournis par l'appelant est une API d'accès aux données
Elle expose l'entité entière. Il faut une liste blanche par entité, avec les chemins écrits en dur. Ce risque n'existe pas avec des requêtes écrites à la main. Il n'existe pas non plus avec des specs nommées par règle métier.
| Critère | Méthode dérivée | @Query JPQL |
Specification | Query by Example | Criteria manuelle / jOOQ |
|---|---|---|---|---|---|
| Filtres optionnels combinables | ❌ Une méthode par combinaison | ❌ :p is null or ... fragile |
✅ Le cas d'usage | ✅ Égalité et like seulement |
✅ Verbeux |
| Validation au démarrage | ✅ | ✅ | ❌ Exécution | ❌ Exécution | ❌ Exécution |
| SQL lisible dans le code | ✅ | ✅ | ❌ Dépend de l'assemblage | ❌ | ✅ (jOOQ) / ❌ (Criteria) |
| Projection précise | ✅ Interface ou select new |
✅ select new |
⚠ Premier niveau seulement | ❌ | ✅ |
Agrégats, GROUP BY |
❌ | ✅ | ❌ | ❌ | ✅ |
| Requête de count contrôlée | ✅ Dérivée | ✅ countQuery |
⚠ Dérivée de la spec | ⚠ Dérivée | ✅ |
SQL avancé (CTE, jsonb) |
❌ | ⚠ Natif seulement | ⚠ Cast Hibernate | ❌ | ✅ |
| Réutilisation des règles | ❌ | ❌ | ✅ | ❌ | ⚠ |
Quand choisir :
@Query JPQL : une requête fixe plus complexe. Une projection sur plusieurs tables. Un agrégat. Un countQuery maîtrisé.exists pour les collections et un helper de jointure.>, pas de between, pas de collection.WHERE à la Criteria manuelle.⚡ TL;DR — chaque concept en une ligne
Specification
✓ Une fonction (root, query, cb) -> Predicate, composable avec and, or, not, allOf, anyOf.
⚠ Ne produit qu'un WHERE : pas de SELECT, pas d'agrégat, pas de GROUP BY.
PredicateSpecification
✓ Prédicat pur sur un From, réutilisable dans un SELECT, un UPDATE, un DELETE ou sur une jointure.
⚠ Pas d'accès à la requête : impossible d'y faire un fetch ou un distinct.
UpdateSpecification / DeleteSpecification
✓ Écritures en masse composables avec les mêmes prédicats que les lectures.
⚠ SQL direct : contexte de persistance non synchronisé, callbacks JPA et Envers ignorés.
unrestricted()
✓ Spec neutre pour un filtre absent, ignorée dans toute composition, remplace les specs nulles.
⚠ where(spec) est retirée en 4.0, et not(unrestricted()) a changé de sens en 3.5.5.
Fluent API findBy
✓ Projection, limite, tri cumulé, slice sans count, scroll keyset, stream.
⚠ Un page(Pageable) trié écrase les sortBy précédents, et le stream doit être fermé.
Métamodèle Order_
✓ Les chemins de propriétés sont vérifiés à la compilation.
⚠ Ne vérifie pas la logique de la spec : une combinaison fautive ne casse qu'à l'exécution.
exists sur une collection
✓ Filtre sans doublon, sans distinct, avec un count correct, et deux exists composés gardent leur sens.
⚠ Un join à la place duplique les lignes et, composé avec un autre join, donne un produit cartésien.
Requête de count dérivée
✓ Remplit Page.getTotalElements() avec la même spec.
⚠ Un fetch la fait échouer sans test sur query.getResultType(), un distinct la ralentit, un orderBy dans la spec est écrasé.
Projection as(...)
✓ Ne lit que les propriétés déclarées depuis Spring Data JPA 3.5.
⚠ Une propriété imbriquée charge le sous-objet complet ; passer par la Criteria manuelle et spec.toPredicate(...).
Spec générique par nom de champ ✓ Permet un filtre libre depuis l'API. ⚠ Expose toutes les colonnes de l'entité : liste blanche obligatoire, chemins écrits en dur.
🎓 À retenir
Page : une fois pour les données, une fois pour le count. Toute logique dans une spec doit être écrite en sachant qu'elle tournera aussi sur une CriteriaQuery<Long>.From plutôt que Root dans PredicateSpecification n'est pas un détail. C'est ce qui permet d'appliquer la même spec sur la table principale et sur une jointure, par exemple hasStatus sur root et sur root.join(lines).query.orderBy(...) dans toPredicate est écrasé dès que le Pageable porte un tri. Il est aussi inutile sur le count. Le tri va dans le Pageable, le Sort ou sortBy.slice à la place de page quand le total n'est pas affiché. Cela supprime la requête de count, et donc tous ses pièges.exists composés et deux join composés n'ont pas le même sens. exists(A).and(exists(B)) : la commande contient A et contient B. join(l1 = A).and(join(l2 = B)) : les lignes sont combinées entre elles, le résultat est faux. Choisir selon la règle métier, pas selon la performance.spec.toPredicate(root, query, cb) fonctionne hors du repository. C'est le pont entre le monde composable des specs et le monde précis des projections et agrégats.Specification.where( et tous les not( posés sur des specs qui peuvent être vides. Les premiers ne compilent plus. Les seconds changent de résultat sans erreur.Specification (4.1) — sémantique de unrestricted(), allOf, anyOf, notPredicateSpecification (4.0) — contrat sur From, élision des specs nullJpaSpecificationExecutor (4.1) — update, delete, findBy, et la note sur le contexte de persistance non synchroniséSpecification.where(spec) is deprecated — origine de la dépréciation et de unrestricted()Specification.unrestricted() to 3.5.x — arrivée de unrestricted() en 3.5.2unrestricted() in not(..) — changement de sémantique de not(unrestricted()) en 3.5.5