Skip to content
Spring Boot sb data-jpa 3 min read

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 — combining null fragments 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

ApproachBest forQuery shapeType-safe buildReusable fragments
Derived queriesFixed, simple criteriaKnown at compile timeMethod-name parsingNo
@Query (JPQL/native)Complex but fixed queriesKnown at compile timeString-basedNo
SpecificationsDynamic, runtime-built filtersComposed at runtimeYes (Criteria API)Yes

Tip: If the condition set is fixed, a derived query or @Query is 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, add query.distinct(true) to avoid duplicate roots; do it inside the relevant toPredicate.
  • 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.
Last updated June 13, 2026
Was this helpful?