Specifications & Criteria API
Some queries can’t be written ahead of time — a search screen where the user may filter by category, price, name, or any combination of them. Hard-coding a method per combination explodes quickly. Specifications let you compose query fragments at runtime using the JPA Criteria API, then hand them to Spring Data for execution with sorting and paging.
A Specification<T> is a functional interface that produces a Predicate, so each filter becomes a small, reusable, testable building block.
Enabling Specifications
Extend JpaSpecificationExecutor<T> on your repository alongside JpaRepository:
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
This adds methods such as findAll(Specification<T>), findAll(Specification<T>, Pageable), findAll(Specification<T>, Sort), count(Specification<T>), and exists(Specification<T>).
A Specification is a functional interface
@FunctionalInterface
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
Root<T> is the entity in the FROM clause, CriteriaQuery<?> is the query being built, and CriteriaBuilder creates predicates and expressions.
Static factory methods
Expose each filter as a static method returning a Specification<Product>. Building them with cb.equal, cb.like, and cb.lessThan keeps each fragment focused.
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.Predicate;
import java.math.BigDecimal;
public final class ProductSpecs {
private ProductSpecs() {}
public static Specification<Product> hasCategory(String category) {
return (root, query, cb) -> cb.equal(root.get("category"), category);
}
public static Specification<Product> priceLessThan(BigDecimal max) {
return (root, query, cb) -> cb.lessThan(root.get("price"), max);
}
public static Specification<Product> nameContains(String text) {
return (root, query, cb) ->
cb.like(cb.lower(root.get("name")), "%" + text.toLowerCase() + "%");
}
}
Combining specifications
Use Specification.where(...), then chain .and(...) / .or(...). Each returns a new immutable Specification.
Specification<Product> spec = Specification
.where(ProductSpecs.hasCategory("books"))
.and(ProductSpecs.priceLessThan(new BigDecimal("50")))
.or(ProductSpecs.nameContains("java"));
Building a spec conditionally in a service
The real payoff is conditional composition: include a fragment only when its filter is present, skipping null parameters.
public record ProductFilter(String category, BigDecimal maxPrice, String name) {}
@Service
public class ProductSearchService {
private final ProductRepository repository;
public ProductSearchService(ProductRepository repository) {
this.repository = repository;
}
public Page<Product> search(ProductFilter filter, Pageable pageable) {
Specification<Product> spec = Specification.where(null);
if (filter.category() != null) {
spec = spec.and(ProductSpecs.hasCategory(filter.category()));
}
if (filter.maxPrice() != null) {
spec = spec.and(ProductSpecs.priceLessThan(filter.maxPrice()));
}
if (filter.name() != null && !filter.name().isBlank()) {
spec = spec.and(ProductSpecs.nameContains(filter.name()));
}
return repository.findAll(spec, pageable);
}
}
For a filter with category books and a max price, the generated SQL contains only the active predicates plus paging:
select p1_0.id, p1_0.category, p1_0.name, p1_0.price
from product p1_0
where p1_0.category = ? and p1_0.price < ?
order by p1_0.name asc
offset ? rows fetch first ? rows only
Note:
Specification.where(null)is a valid no-op starting point — combiningnullfragments is ignored, so an empty filter returns all rows (subject to paging).
Other executor methods
Beyond paged search, JpaSpecificationExecutor also gives you:
long count = repository.count(spec);
boolean any = repository.exists(spec);
List<Product> all = repository.findAll(spec, Sort.by("name"));
When to use which approach
| Approach | Best for | Query shape | Type-safe build | Reusable fragments |
|---|---|---|---|---|
| Derived queries | Fixed, simple criteria | Known at compile time | Method-name parsing | No |
@Query (JPQL/native) | Complex but fixed queries | Known at compile time | String-based | No |
| Specifications | Dynamic, runtime-built filters | Composed at runtime | Yes (Criteria API) | Yes |
Tip: If the condition set is fixed, a derived query or
@Queryis simpler to read. Reach for Specifications when the combination of filters is decided at runtime.
Pitfalls
query.distinct(true)on joins: when a spec joins a@OneToMany, addquery.distinct(true)to avoid duplicate roots; do it inside the relevanttoPredicate.- Count query and joins: Spring builds a separate count query for paging; fetch-joins in a spec can break it — prefer plain joins and let projections/EAGER tuning handle loading.
- Stringly-typed attributes:
root.get("price")fails at runtime if the property name is wrong; consider the generated JPA static metamodel (Product_.price) for compile-time safety. - Over-engineering: don’t convert every query to a Specification; reserve it for genuinely dynamic search.