Spring BootCriteria API
Spring Boot

Criteria API

The JPA Criteria API builds type-safe, dynamic queries programmatically using a fluent builder rather than string-based JPQL. CriteriaBuilder constructs the query structure; Root defines the FROM clause; Predicate expresses WHERE conditions. Spring Data JPA's Specification interface wraps a single Predicate and composes with Specification.where().and().or() for clean, reusable query building.

CriteriaBuilder Basics

CriteriaBuilder is the factory for all query objects and predicates. Inject it from EntityManager. CriteriaQuery defines the return type and carries the SELECT and WHERE clauses. Root is the query's FROM clause and the entry point for navigating entity attributes. The query is executed through EntityManager.createQuery().
Java
@Repository
@RequiredArgsConstructor
public class ProductCriteriaRepository {

    @PersistenceContext
    private final EntityManager em;

    // ── Simple select all ─────────────────────────────────────────────
    public List<Product> findAll() {
        CriteriaBuilder  cb    = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq = cb.createQuery(Product.class);
        Root<Product>    root  = cq.from(Product.class);

        cq.select(root);              // SELECT p FROM Product p

        return em.createQuery(cq).getResultList();
    }

    // ── Select with a single WHERE condition ──────────────────────────
    public List<Product> findByStatus(ProductStatus status) {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        Predicate statusEq = cb.equal(root.get("status"), status);
        cq.select(root).where(statusEq);

        return em.createQuery(cq).getResultList();
    }

    // ── Multiple WHERE conditions ──────────────────────────────────────
    public List<Product> findByCategoryAndMinPrice(
            String category, BigDecimal minPrice) {

        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        List<Predicate> predicates = new ArrayList<>();
        predicates.add(cb.equal(root.get("category"), category));
        predicates.add(cb.greaterThanOrEqualTo(
            root.get("price"), minPrice));

        cq.select(root)
          .where(cb.and(predicates.toArray(new Predicate[0])));

        return em.createQuery(cq).getResultList();
    }

    // ── ORDER BY and pagination ────────────────────────────────────────
    public List<Product> findAllSorted(int page, int size) {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        cq.select(root)
          .orderBy(
              cb.desc(root.get("createdAt")),
              cb.asc(root.get("name"))
          );

        return em.createQuery(cq)
            .setFirstResult(page * size)
            .setMaxResults(size)
            .getResultList();
    }
}

Predicates and Expressions

CriteriaBuilder provides a predicate for every SQL comparison operator. Predicates combine with cb.and(), cb.or(), and cb.not(). Path expressions navigate associations and embedded objects using Root.get() and Join.get(). Use cb.literal() for constant values and cb.parameter() for named parameters.
Java
@Repository
@RequiredArgsConstructor
public class OrderCriteriaRepository {

    @PersistenceContext
    private final EntityManager em;

    public List<Order> findWithFilters(OrderFilter filter) {
        CriteriaBuilder       cb   = em.getCriteriaBuilder();
        CriteriaQuery<Order>  cq   = cb.createQuery(Order.class);
        Root<Order>           root = cq.from(Order.class);

        List<Predicate> predicates = new ArrayList<>();

        // ── Equality ──────────────────────────────────────────────────
        if (filter.getStatus() != null) {
            predicates.add(cb.equal(root.get("status"),
                filter.getStatus()));
        }

        // ── Range ─────────────────────────────────────────────────────
        if (filter.getMinTotal() != null) {
            predicates.add(cb.greaterThanOrEqualTo(
                root.get("total"), filter.getMinTotal()));
        }
        if (filter.getMaxTotal() != null) {
            predicates.add(cb.lessThanOrEqualTo(
                root.get("total"), filter.getMaxTotal()));
        }

        // ── Date range ────────────────────────────────────────────────
        if (filter.getFrom() != null) {
            predicates.add(cb.greaterThanOrEqualTo(
                root.get("createdAt"), filter.getFrom().atStartOfDay()));
        }
        if (filter.getTo() != null) {
            predicates.add(cb.lessThanOrEqualTo(
                root.get("createdAt"), filter.getTo().atTime(23, 59, 59)));
        }

        // ── LIKE ──────────────────────────────────────────────────────
        if (filter.getReference() != null) {
            predicates.add(cb.like(
                cb.lower(root.get("reference")),
                "%" + filter.getReference().toLowerCase() + "%"));
        }

        // ── IN ────────────────────────────────────────────────────────
        if (filter.getStatuses() != null && !filter.getStatuses().isEmpty()) {
            predicates.add(root.get("status").in(filter.getStatuses()));
        }

        // ── IS NULL / IS NOT NULL ────────────────────────────────────
        if (Boolean.TRUE.equals(filter.getShipped())) {
            predicates.add(cb.isNotNull(root.get("shippedAt")));
        } else if (Boolean.FALSE.equals(filter.getShipped())) {
            predicates.add(cb.isNull(root.get("shippedAt")));
        }

        // ── BETWEEN ───────────────────────────────────────────────────
        if (filter.getMinTotal() != null && filter.getMaxTotal() != null) {
            predicates.add(cb.between(root.get("total"),
                filter.getMinTotal(), filter.getMaxTotal()));
        }

        // ── OR condition ──────────────────────────────────────────────
        if (filter.getKeyword() != null) {
            String pattern = "%" + filter.getKeyword().toLowerCase() + "%";
            predicates.add(cb.or(
                cb.like(cb.lower(root.get("reference")), pattern),
                cb.like(cb.lower(root.get("notes")),     pattern)
            ));
        }

        cq.select(root)
          .where(cb.and(predicates.toArray(new Predicate[0])))
          .orderBy(cb.desc(root.get("createdAt")));

        return em.createQuery(cq).getResultList();
    }
}

Joins

Navigate associations with Root.join() or Root.joinSet() / Root.joinList(). Joins default to INNER; use JoinType.LEFT for optional associations. Fetch joins load associations eagerly in one query to avoid N+1, using Root.fetch() instead of Root.join().
Java
@Repository
@RequiredArgsConstructor
public class ProductCriteriaRepository {

    @PersistenceContext
    private final EntityManager em;

    // ── INNER JOIN ─────────────────────────────────────────────────────
    public List<Product> findByCategoryName(String categoryName) {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        Join<Product, Category> category =
            root.join("category", JoinType.INNER);

        cq.select(root)
          .where(cb.equal(category.get("name"), categoryName))
          .distinct(true);

        return em.createQuery(cq).getResultList();
    }

    // ── LEFT JOIN — include products without a category ───────────────
    public List<Product> findAllWithCategory() {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        root.join("category", JoinType.LEFT);   // products without category included

        cq.select(root).distinct(true);
        return em.createQuery(cq).getResultList();
    }

    // ── FETCH JOIN — load association eagerly to avoid N+1 ───────────
    public List<Order> findOrdersWithItems(OrderStatus status) {
        CriteriaBuilder       cb   = em.getCriteriaBuilder();
        CriteriaQuery<Order>  cq   = cb.createQuery(Order.class);
        Root<Order>           root = cq.from(Order.class);

        root.fetch("items",    JoinType.LEFT);   // fetch items in same query
        root.fetch("customer", JoinType.LEFT);   // fetch customer too

        cq.select(root)
          .where(cb.equal(root.get("status"), status))
          .distinct(true);    // DISTINCT required with collection fetch

        return em.createQuery(cq).getResultList();
    }

    // ── Multi-level join ──────────────────────────────────────────────
    public List<Product> findBySupplierCountry(String countryCode) {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq   = cb.createQuery(Product.class);
        Root<Product>          root = cq.from(Product.class);

        Join<Product,  Supplier> supplier =
            root.join("supplier", JoinType.INNER);
        Join<Supplier, Address>  address  =
            supplier.join("address", JoinType.INNER);

        cq.select(root)
          .where(cb.equal(address.get("countryCode"), countryCode));

        return em.createQuery(cq).getResultList();
    }
}

Aggregate Queries and Projections

Aggregate queries use CriteriaBuilder functions (count, sum, avg, min, max) and group with groupBy() and having(). Projections select a subset of fields into a constructor expression or a Tuple rather than loading full entities.
Java
@Repository
@RequiredArgsConstructor
public class ReportCriteriaRepository {

    @PersistenceContext
    private final EntityManager em;

    // ── COUNT ──────────────────────────────────────────────────────────
    public long countByStatus(ProductStatus status) {
        CriteriaBuilder   cb   = em.getCriteriaBuilder();
        CriteriaQuery<Long> cq = cb.createQuery(Long.class);
        Root<Product>     root = cq.from(Product.class);

        cq.select(cb.count(root))
          .where(cb.equal(root.get("status"), status));

        return em.createQuery(cq).getSingleResult();
    }

    // ── SUM / AVG ─────────────────────────────────────────────────────
    public BigDecimal averagePrice(String category) {
        CriteriaBuilder         cb   = em.getCriteriaBuilder();
        CriteriaQuery<Double>   cq   = cb.createQuery(Double.class);
        Root<Product>           root = cq.from(Product.class);

        cq.select(cb.avg(root.get("price")))
          .where(cb.equal(root.get("category"), category));

        Double result = em.createQuery(cq).getSingleResult();
        return result == null ? BigDecimal.ZERO
            : BigDecimal.valueOf(result);
    }

    // ── GROUP BY with aggregate ────────────────────────────────────────
    public List<Object[]> countByCategory() {
        CriteriaBuilder          cb   = em.getCriteriaBuilder();
        CriteriaQuery<Object[]>  cq   = cb.createQuery(Object[].class);
        Root<Product>            root = cq.from(Product.class);

        cq.multiselect(
            root.get("category"),
            cb.count(root),
            cb.avg(root.get("price"))
        )
        .groupBy(root.get("category"))
        .orderBy(cb.desc(cb.count(root)));

        return em.createQuery(cq).getResultList();
    }

    // ── Constructor expression — project into a DTO ───────────────────
    public List<ProductSummary> findSummaries() {
        CriteriaBuilder                  cb   = em.getCriteriaBuilder();
        CriteriaQuery<ProductSummary>    cq   =
            cb.createQuery(ProductSummary.class);
        Root<Product>                    root = cq.from(Product.class);

        cq.select(cb.construct(
            ProductSummary.class,       // DTO class
            root.get("id"),
            root.get("name"),
            root.get("price"),
            root.get("status")
        ));

        return em.createQuery(cq).getResultList();
    }

    // ── Tuple — ad-hoc projection without a DTO class ─────────────────
    public List<Tuple> findIdAndName() {
        CriteriaBuilder        cb   = em.getCriteriaBuilder();
        CriteriaQuery<Tuple>   cq   = cb.createTupleQuery();
        Root<Product>          root = cq.from(Product.class);

        cq.multiselect(
            root.get("id").alias("id"),
            root.get("name").alias("name"),
            root.get("price").alias("price")
        );

        return em.createQuery(cq).getResultList()
            .stream()
            .peek(t -> {
                Long   id    = t.get("id",    Long.class);
                String name  = t.get("name",  String.class);
            })
            .toList();
    }
}

Spring Data Specifications

Spring Data's Specification<T> interface wraps a single Criteria API Predicate in a functional interface. Specifications compose with Specification.where().and().or() and plug directly into JpaSpecificationExecutor. This is the cleanest pattern for dynamic multi-criteria filtering in a Spring Boot application.
Java
// ── Repository ────────────────────────────────────────────────────────
public interface ProductRepository
        extends JpaRepository<Product, Long>,
                JpaSpecificationExecutor<Product> {}

// ── Specification factory ─────────────────────────────────────────────
public class ProductSpecs {

    public static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) ->
            status == null ? null
                           : cb.equal(root.get("status"), status);
    }

    public static Specification<Product> nameLike(String name) {
        return (root, query, cb) ->
            name == null ? null
                         : cb.like(cb.lower(root.get("name")),
                                   "%" + name.toLowerCase() + "%");
    }

    public static Specification<Product> priceBetween(
            BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min == null) return cb.lessThanOrEqualTo(
                root.get("price"), max);
            if (max == null) return cb.greaterThanOrEqualTo(
                root.get("price"), min);
            return cb.between(root.get("price"), min, max);
        };
    }

    public static Specification<Product> inCategory(String category) {
        return (root, query, cb) ->
            category == null ? null
                             : cb.equal(
                                 root.join("category", JoinType.INNER)
                                     .get("name"),
                                 category);
    }

    public static Specification<Product> createdAfter(LocalDate date) {
        return (root, query, cb) ->
            date == null ? null
                         : cb.greaterThanOrEqualTo(
                               root.get("createdAt"),
                               date.atStartOfDay());
    }
}

// ── Service — compose specifications ──────────────────────────────────
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepo;

    public Page<ProductResponse> search(ProductFilter f, Pageable pageable) {
        Specification<Product> spec = Specification
            .where(hasStatus(f.getStatus()))
            .and(nameLike(f.getName()))
            .and(priceBetween(f.getMinPrice(), f.getMaxPrice()))
            .and(inCategory(f.getCategory()))
            .and(createdAfter(f.getCreatedAfter()));

        return productRepo.findAll(spec, pageable)
                          .map(ProductResponse::from);
    }
}

// ── Controller ────────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<ProductResponse>> search(
        @ModelAttribute @Valid ProductFilter filter,
        @PageableDefault(size = 20) Pageable pageable) {
    return ResponseEntity.ok(
        PageResponse.from(productService.search(filter, pageable)));
}

Reusable Specification Library

Extract common predicate patterns into a generic specification utility class. Equality, range, like, in, null checks, and date range are the same across every entity. A shared library avoids duplication and gives every entity a consistent filtering API.
Java
// ── Generic specification utilities ──────────────────────────────────
public class Specs {

    // ── Equality ──────────────────────────────────────────────────────
    public static <T, V> Specification<T> eq(
            String field, V value) {
        return (root, query, cb) ->
            value == null ? null : cb.equal(root.get(field), value);
    }

    // ── LIKE (case-insensitive) ────────────────────────────────────────
    public static <T> Specification<T> like(
            String field, String value) {
        return (root, query, cb) ->
            value == null ? null
                          : cb.like(cb.lower(root.get(field)),
                                    "%" + value.toLowerCase() + "%");
    }

    // ── Greater than or equal ─────────────────────────────────────────
    public static <T, V extends Comparable<V>> Specification<T> gte(
            String field, V value) {
        return (root, query, cb) ->
            value == null ? null
                          : cb.greaterThanOrEqualTo(root.get(field), value);
    }

    // ── Less than or equal ────────────────────────────────────────────
    public static <T, V extends Comparable<V>> Specification<T> lte(
            String field, V value) {
        return (root, query, cb) ->
            value == null ? null
                          : cb.lessThanOrEqualTo(root.get(field), value);
    }

    // ── IN ────────────────────────────────────────────────────────────
    public static <T, V> Specification<T> in(
            String field, Collection<V> values) {
        return (root, query, cb) ->
            (values == null || values.isEmpty())
                ? null : root.get(field).in(values);
    }

    // ── IS NULL ───────────────────────────────────────────────────────
    public static <T> Specification<T> isNull(String field) {
        return (root, query, cb) -> cb.isNull(root.get(field));
    }

    // ── IS NOT NULL ───────────────────────────────────────────────────
    public static <T> Specification<T> isNotNull(String field) {
        return (root, query, cb) -> cb.isNotNull(root.get(field));
    }

    // ── Date range ────────────────────────────────────────────────────
    public static <T> Specification<T> dateBetween(
            String field, LocalDate from, LocalDate to) {
        return Specification
            .where(gte(field, from != null ? from.atStartOfDay() : null))
            .and(lte(field, to   != null ? to.atTime(23, 59, 59) : null));
    }
}

// ── Usage with the generic library ────────────────────────────────────
public class OrderSpecs {

    public static Specification<Order> build(OrderFilter f) {
        return Specification
            .where(Specs.<Order, OrderStatus>eq("status", f.getStatus()))
            .and(Specs.<Order, BigDecimal>gte("total",  f.getMinTotal()))
            .and(Specs.<Order, BigDecimal>lte("total",  f.getMaxTotal()))
            .and(Specs.<Order>like("reference",          f.getReference()))
            .and(Specs.<Order>in("status",               f.getStatuses()))
            .and(Specs.<Order>dateBetween("createdAt",
                                          f.getFrom(), f.getTo()));
    }
}