Spring Data JPA Specification API

Last update: 2022-11-09 01:13:09 +0800 +0800
tags: Java | Spring | Jpa
Available translations: 日本語 / 中文(简体) / 中文(繁體)

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:

  1. Spring Data repository methods such as findAll(...), count(...), and exists(...)
  2. Spring’s org.springframework.data.jpa.domain.Specification<T>
  3. the Jakarta Persistence Criteria API centered around jakarta.persistence.criteria.CriteriaBuilder

JpaSpecificationExecutor<T> #

Suppose we have an entity:

java
@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:

java
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:

java
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:

java
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:

  1. Root<T> represents the root table/entity being queried
  2. CriteriaQuery<?> is the whole query under construction
  3. CriteriaBuilder is the factory for expressions and predicates such as equal, 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:

java
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:

java
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:

java
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:

  1. comparison predicates: equal, notEqual, greaterThan, lessThan, between
  2. string operations: like, lower, upper, trim, concat
  3. null/boolean checks: isNull, isNotNull, isTrue, isFalse
  4. boolean composition: and, or, not, conjunction, disjunction
  5. aggregate or query expressions: count, avg, sum, exists

Examples:

java
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:

java
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.

java
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:

java
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(...):

java
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:

  1. many repository methods like findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...)
  2. 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:

  1. search screens with many optional fields
  2. admin APIs with pageable filtering
  3. 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:

java
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:

java
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:

  1. application creates or composes a Specification<T>
  2. application calls repository.findAll(spec, pageable)
  3. Spring Data obtains EntityManager infrastructure
  4. Spring Data builds a Criteria query
  5. your Specification contributes the predicate tree
  6. Hibernate translates that Criteria tree into SQL
  7. 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:

  1. JpaSpecificationExecutor<T> is the repository entry point
  2. Specification<T> is the reusable filter abstraction
  3. CriteriaBuilder is the factory that creates the predicate/expression tree
  4. Root<T>, Path<?>, Join<?, ?>, Predicate, and CriteriaQuery<?> 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)