When a Spring Data JPA repository needs a WHERE clause that is optional, composable, and decided at runtime, the usual API is org.springframework.data.jpa.repository.JpaSpecificationExecutor<T>.
It is the bridge between:
- Spring Data repository methods such as
findAll(...),count(...), andexists(...) - Spring’s
org.springframework.data.jpa.domain.Specification<T> - the Jakarta Persistence Criteria API centered around
jakarta.persistence.criteria.CriteriaBuilder
JpaSpecificationExecutor<T>
#
Suppose we have an entity:
@Entity
@Table(name = "orders")
public class PurchaseOrder {
@Id
private Long id;
@Column(nullable = false)
private String status;
@Column(nullable = false)
private String customerEmail;
@Column(nullable = false)
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}and a repository:
public interface PurchaseOrderRepository
extends JpaRepository<PurchaseOrder, Long>,
JpaSpecificationExecutor<PurchaseOrder> {
}JpaRepository gives CRUD and paging APIs. JpaSpecificationExecutor adds methods whose filtering logic is described by a Specification<PurchaseOrder>.
Typical methods are:
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec);
Page<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Pageable pageable);
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Sort sort);
Optional<PurchaseOrder> findOne(Specification<PurchaseOrder> spec);
long count(Specification<PurchaseOrder> spec);
boolean exists(Specification<PurchaseOrder> spec);So the central idea is simple: you do not manually assemble JPQL strings. You hand Spring Data a Specification<T>, and it builds the Criteria query for you.
What Specification<T> Really Is
#
org.springframework.data.jpa.domain.Specification<T> is a functional interface:
public interface Specification<T> extends Serializable {
Predicate toPredicate(
Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder
);
}This means a Specification<T> is just a reusable function that knows how to contribute a Predicate for entity type T.
The three parameters matter:
Root<T>represents the root table/entity being queriedCriteriaQuery<?>is the whole query under constructionCriteriaBuilderis the factory for expressions and predicates such asequal,like,between,and,or,not,lower,count, etc.
In practice, most application code only cares about root and criteriaBuilder. query becomes important when you need things like distinct, groupBy, custom ordering, or fetch joins.
How to Obtain a Specification in Practice
#
The most common way is to return one from small static factory methods:
public final class PurchaseOrderSpecs {
private PurchaseOrderSpecs() {}
public static Specification<PurchaseOrder> hasStatus(String status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<PurchaseOrder> customerEmailContains(String token) {
return (root, query, cb) ->
cb.like(
cb.lower(root.get("customerEmail")),
"%" + token.toLowerCase(Locale.ROOT) + "%"
);
}
public static Specification<PurchaseOrder> createdAtGte(Instant lowerBound) {
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), lowerBound);
}
public static Specification<PurchaseOrder> customerIdEquals(Long customerId) {
return (root, query, cb) -> cb.equal(root.get("customer").get("id"), customerId);
}
}Then compose them:
Specification<PurchaseOrder> spec = Specification.where(null);
if (status != null) {
spec = spec.and(PurchaseOrderSpecs.hasStatus(status));
}
if (emailToken != null && !emailToken.isBlank()) {
spec = spec.and(PurchaseOrderSpecs.customerEmailContains(emailToken));
}
if (createdAfter != null) {
spec = spec.and(PurchaseOrderSpecs.createdAtGte(createdAfter));
}
if (customerId != null) {
spec = spec.and(PurchaseOrderSpecs.customerIdEquals(customerId));
}
Page<PurchaseOrder> page =
purchaseOrderRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));This is the practical shape of the API in most codebases: request parameters are turned into a chain of optional Specifications.
Another common style is to keep the request object and let one method build the whole specification:
public record PurchaseOrderFilter(
String status,
String emailToken,
Instant createdAfter,
Long customerId
) {}
public static Specification<PurchaseOrder> fromFilter(PurchaseOrderFilter filter) {
Specification<PurchaseOrder> spec = Specification.where(null);
if (filter.status() != null) {
spec = spec.and(hasStatus(filter.status()));
}
if (filter.emailToken() != null && !filter.emailToken().isBlank()) {
spec = spec.and(customerEmailContains(filter.emailToken()));
}
if (filter.createdAfter() != null) {
spec = spec.and(createdAtGte(filter.createdAfter()));
}
if (filter.customerId() != null) {
spec = spec.and(customerIdEquals(filter.customerId()));
}
return spec;
}How CriteriaBuilder Works
#
jakarta.persistence.criteria.CriteriaBuilder is a factory API. It does not execute SQL by itself. Instead, it creates a tree of expression objects that the JPA provider (for example Hibernate) later translates into SQL.
Common categories of methods are:
- comparison predicates:
equal,notEqual,greaterThan,lessThan,between - string operations:
like,lower,upper,trim,concat - null/boolean checks:
isNull,isNotNull,isTrue,isFalse - boolean composition:
and,or,not,conjunction,disjunction - aggregate or query expressions:
count,avg,sum,exists
Examples:
cb.equal(root.get("status"), "PAID");
cb.like(cb.lower(root.get("customerEmail")), "%gmail.com");
cb.between(root.get("createdAt"), start, end);
cb.and(
cb.equal(root.get("status"), "PAID"),
cb.isNotNull(root.get("customerEmail"))
);So the Criteria API is basically a typed AST builder for SQL-ish queries.
The Important Criteria API Types Around It #
To understand Specification, the following interfaces are the ones worth recognizing:
Root<T>
#
Root<T> is the root entity in the FROM clause. If the repository is querying PurchaseOrder, then root corresponds to that table/entity.
Examples:
root.get("status");
root.get("createdAt");
root.get("customer").get("id");Path<X>
#
Every root.get(...) returns a Path<?>, representing navigation to an attribute. This is why chained property access works.
Path<String> email = root.get("customerEmail");
Path<Long> customerId = root.get("customer").get("id");Predicate
#
Predicate is the boolean expression returned by toPredicate(...). It becomes part of the generated WHERE clause.
CriteriaQuery<?>
#
CriteriaQuery<?> is the full query definition. A specification can mutate it when needed:
return (root, query, cb) -> {
query.distinct(true);
return cb.equal(root.get("status"), "PAID");
};Be careful here: once a specification starts changing the query shape, it is no longer just a tiny filter fragment. This is powerful, but it also makes composition less predictable.
Join<Z, X>
#
When filtering by related entities, an explicit join is often clearer than chained get(...):
public static Specification<PurchaseOrder> customerRegionEquals(String region) {
return (root, query, cb) -> {
Join<PurchaseOrder, Customer> customer = root.join("customer");
return cb.equal(customer.get("region"), region);
};
}Order
#
Ordering is usually better handled by Spring Data’s Sort or Pageable, not hardcoded inside the specification. But CriteriaBuilder.asc(...) / desc(...) exist if a spec must influence ordering through query.orderBy(...).
Why Specification Exists at All
#
Without Specification, you usually end up with one of these two problems:
- many repository methods like
findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...) - hand-written JPQL strings with lots of nullable conditions
Specification solves the “dynamic WHERE clause” problem by letting each filter be an object/function that can be combined with .and(...) and .or(...).
This is especially useful for:
- search screens with many optional fields
- admin APIs with pageable filtering
- reusable business filters such as “visible to tenant”, “not soft-deleted”, or “created by current user”
Type-Safe Access vs. String Property Names #
Examples often use:
root.get("status")which is convenient, but stringly typed. A typo fails only at runtime.
If your build generates the JPA static metamodel, a safer version is:
root.get(PurchaseOrder_.status)This is not required to use Specification, but it is an important companion API if you want refactor-safe Criteria code.
What Spring Data Is Doing for You Behind the Scenes #
In normal application code, you do not create CriteriaBuilder yourself. Spring Data’s JPA repository implementation obtains an EntityManager, asks it for a CriteriaBuilder, creates a CriteriaQuery, calls your Specification#toPredicate(...), and then executes the final query through the JPA provider.
So the control flow is roughly:
- application creates or composes a
Specification<T> - application calls
repository.findAll(spec, pageable) - Spring Data obtains
EntityManagerinfrastructure - Spring Data builds a Criteria query
- your
Specificationcontributes the predicate tree - Hibernate translates that Criteria tree into SQL
- the database executes it
This explains why Specification feels small: it is only the filter fragment, not the whole repository implementation.
A Practical Rule of Thumb #
Use JpaSpecificationExecutor when the query is still “one entity root plus a dynamic predicate tree”. Once the query becomes a report with projections, aggregates, complex grouping, vendor-specific SQL, or tricky fetch plans, the Specification API becomes less pleasant, and a custom repository implementation or another query style is often clearer.
For the normal search/filter use case, though, the stack is elegant:
JpaSpecificationExecutor<T>is the repository entry pointSpecification<T>is the reusable filter abstractionCriteriaBuilderis the factory that creates the predicate/expression treeRoot<T>,Path<?>,Join<?, ?>,Predicate, andCriteriaQuery<?>are the main supporting types
Once that mental model is clear, the API becomes much less mysterious.
Comments
Read or comment on GitHub (sign-in required to comment)