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()));
}
}