Back to Queries: JPQL, Native, Derived, Criteria
Q6Question 6 of 6

What is the Criteria API / Specification?

Key Concepts
  • The Criteria API builds queries programmatically as a tree of objects instead of as a string, making them type-safe and dynamic
  • CriteriaBuilder is the factory; CriteriaQuery is the query; Root is the entity; Predicate is a condition
  • Specification<T> is a composable Spring Data wrapper where each specification contributes one predicate via toPredicate()
  • Optional filters: a specification returns null when its input is absent, causing that filter to be skipped in composition
  • Combine specifications with Specification.where(spec1).and(spec2).or(spec3) for clean boolean logic
  • The JPA Metamodel (Account_.balance) provides compile-time safety so a renamed field breaks the build, not runtime
  • Reserve Criteria/Specification for dynamic queries; use JPQL or @NamedQuery for fixed queries where readability matters

Definition

The Criteria API builds queries as type-safe object trees for dynamic filters; Specification<T> is a composable Spring Data wrapper that combines predicates with and/or -- no string concatenation needed.

JPA and Spring Data provide two layers for building dynamic, type-safe queries.

  • Criteria API – builds queries programmatically using objects (CriteriaBuilder, CriteriaQuery, Root, Predicate) instead of strings, enabling dynamic query construction where the set of filters is not known until runtime.
  • Specification – a Spring Data functional interface with one method toPredicate(root, query, criteriaBuilder) that contributes a single predicate; specifications are composed with and(), or(), and not() for clean boolean logic.
  • JPA Metamodel – auto-generated classes (Account_, Transaction_) that provide compile-time field references so a renamed field breaks the build instead of failing at runtime.
  • JpaSpecificationExecutor – a Spring Data repository interface that adds findAll(Specification) with built-in support for paging and sorting.

At National Bank of India, imagine a search screen for accounts with many optional filters: minimum balance, account type, status, holder name, and opened-after date. Priya might fill in none, one, or all of them. Building the SQL by gluing strings together is like writing the search request freehand and hoping the ANDs land in the right places. The Criteria API is like assembling the request from a box of labeled building blocks: each filter is a block, you only add the blocks the customer actually filled in, and the blocks are guaranteed to fit together correctly. Specification is a kit of those blocks that snap together with and/or.

Understanding the Concepts

What is the Criteria API?

The Criteria API builds queries programmatically as a tree of objects instead of as a JPQL string. CriteriaBuilder is the factory that creates predicates and expressions. CriteriaQuery represents the query itself. Root represents the entity being queried. And each condition is a Predicate that you add to the WHERE clause.

This approach makes queries type-safe and ideal for dynamic construction, where the set of filters is not known until runtime.

JAVA
package com.bank.service;

import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class AccountSearchService {

    private final EntityManager em;

    public AccountSearchService(EntityManager em) { this.em = em; }

    public List<Account> search(String accountType, BigDecimal minBalance, String status) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Account> cq = cb.createQuery(Account.class);
        Root<Account> root = cq.from(Account.class);

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

        if (accountType != null) {
            predicates.add(cb.equal(root.get("accountType"), accountType));
        }
        if (minBalance != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("balance"), minBalance));
        }
        if (status != null) {
            predicates.add(cb.equal(root.get("status"), status));
        }

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

        return em.createQuery(cq).getResultList();
    }
}
TEXT
Expected output (accountType="SAVINGS", minBalance=50000, status=null):
[Account{SAV2001, Priya, 500000.00, SAVINGS},
 Account{SAV2003, Rajesh, 150000.00, SAVINGS}]

What is Specification?

Specification is a Spring Data functional interface that wraps the Criteria API. Each specification contributes one predicate through its toPredicate(root, query, criteriaBuilder) method. You write small, reusable specifications per filter and compose them with and(), or(), and not().

JAVA
package com.bank.spec;

import com.bank.model.Account;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;

public class AccountSpecs {

    public static Specification<Account> hasType(String type) {
        return (root, query, cb) ->
            type == null ? null : cb.equal(root.get("accountType"), type);
    }

    public static Specification<Account> balanceAtLeast(BigDecimal min) {
        return (root, query, cb) ->
            min == null ? null : cb.greaterThanOrEqualTo(root.get("balance"), min);
    }

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

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

Returning null from a specification when its input is absent means that filter is simply skipped. This is the elegant solution to optional filters.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import com.bank.spec.AccountSpecs;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;
import java.util.List;

public class AccountService {

    private final AccountRepository repo;

    public AccountService(AccountRepository repo) { this.repo = repo; }

    public List<Account> search(
            String type, BigDecimal minBalance, String status, String name) {

        Specification<Account> spec = Specification
            .where(AccountSpecs.hasType(type))
            .and(AccountSpecs.balanceAtLeast(minBalance))
            .and(AccountSpecs.hasStatus(status))
            .and(AccountSpecs.holderNameContains(name));

        return repo.findAll(spec);
    }
}
TEXT
Expected output (type="SAVINGS", minBalance=null, status="ACTIVE", name=null):
[Account{SAV2001, Priya, 500000.00, SAVINGS, ACTIVE},
 Account{SAV2003, Rajesh, 150000.00, SAVINGS, ACTIVE}]
// minBalance and name filters were null, so they were skipped

The repository must extend JpaSpecificationExecutor:

JAVA
package com.bank.repository;

import com.bank.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface AccountRepository
        extends JpaRepository<Account, String>,
                JpaSpecificationExecutor<Account> {
}

Why do we need dynamic queries?

Fixed JPQL is fine when you know the exact query at compile time. But many real-world use cases require queries that change shape at runtime.

At National Bank of India, the account search screen has 8 optional filters. Priya might search by type and status, Rajesh might search by minimum balance only, and Pankaj might use all 8 filters together. With fixed JPQL, you would need every possible combination of filters as a separate query -- that is 2^8 = 256 queries.

With the Criteria API or specifications, you build one query dynamically by adding only the predicates that apply. No string concatenation, no fragment juggling, no risk of malformed queries.

JAVA
// The problem: building dynamic JPQL with string concatenation
// WRONG -- fragile, error-prone, injection-risky
public List<Account> search(String type, BigDecimal min) {
    StringBuilder jpql = new StringBuilder("SELECT a FROM Account a WHERE 1=1");
    if (type != null) jpql.append(" AND a.accountType = '" + type + "'"); // INJECTION!
    if (min != null) jpql.append(" AND a.balance >= " + min);
    return em.createQuery(jpql.toString(), Account.class).getResultList();
}

// CORRECT -- use Criteria API or Specification
// No string concatenation, no injection risk
Specification<Account> spec = Specification
    .where(AccountSpecs.hasType(type))
    .and(AccountSpecs.balanceAtLeast(min));
return repo.findAll(spec);

How does the Criteria API work internally?

When you build a Criteria query, you are constructing a tree of objects that Hibernate translates into SQL. The CriteriaBuilder creates nodes in this tree: predicates for WHERE, expressions for SELECT, and orders for ORDER BY. When you call em.createQuery(criteriaQuery), Hibernate walks the tree and generates the corresponding SQL for the configured database dialect.

DIAGRAM
CriteriaQuery<Account>
  ├── SELECT: root (Account)
  ├── FROM: Account (root)
  ├── WHERE:
  │     AND
  │     ├── equal(accountType, "SAVINGS")
  │     └── ge(balance, 50000)
  └── ORDER BY: desc(balance)

Hibernate translates to:
  SELECT a.account_id, a.holder_name, a.balance, a.account_type, a.status
  FROM accounts a
  WHERE a.account_type = 'SAVINGS' AND a.balance >= 50000
  ORDER BY a.balance DESC

The key insight is that each object in the tree is strongly typed. root.get("balance") returns a Path, and cb.greaterThanOrEqualTo() expects compatible types. This catches type mismatches at compile time when used with the metamodel.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.model.Account_; // Generated metamodel
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.*;
import java.math.BigDecimal;
import java.util.List;

public class TypeSafeSearchService {

    private final EntityManager em;

    public TypeSafeSearchService(EntityManager em) { this.em = em; }

    public List<Account> findHighBalanceSavings() {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Account> cq = cb.createQuery(Account.class);
        Root<Account> root = cq.from(Account.class);

        cq.where(
            cb.and(
                cb.equal(root.get(Account_.accountType), "SAVINGS"),
                cb.greaterThanOrEqualTo(root.get(Account_.balance),
                    new BigDecimal("100000.00"))
            )
        );

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

What is the JPA Metamodel?

The JPA Metamodel is a set of classes generated at compile time that provide type-safe field references. Instead of using string field names like root.get("balance"), you use root.get(Account_.balance). If a field is renamed, the build breaks immediately instead of failing at runtime.

To generate the metamodel, add the Hibernate JPA Metamodel Generator to your build:

XML
<!-- Maven dependency --syn-kw">>
syn-kw"><dependencysyn-kw">>
    syn-kw"><groupIdsyn-kw">>org.hibernate.ormsyn-kw"></groupIdsyn-kw">>
    syn-kw"><artifactIdsyn-kw">>hibernate-jpamodelgensyn-kw"></artifactIdsyn-kw">>
    syn-kw"><scopesyn-kw">>providedsyn-kw"></scopesyn-kw">>
syn-kw"></dependencysyn-kw">>

The generator creates a class like this:

JAVA
// Auto-generated: Account_.java
package com.bank.model;

import jakarta.persistence.metamodel.SingularAttribute;
import jakarta.persistence.metamodel.StaticMetamodel;
import java.math.BigDecimal;

@StaticMetamodel(Account.class)
public abstract class Account_ {
    public static volatile SingularAttribute<Account, String> accountId;
    public static volatile SingularAttribute<Account, String> holderName;
    public static volatile SingularAttribute<Account, BigDecimal> balance;
    public static volatile SingularAttribute<Account, String> accountType;
    public static volatile SingularAttribute<Account, String> status;
}

Now you use Account_.balance instead of the string "balance". If someone renames the field, the compiler catches it.

JAVA
// Without metamodel -- string, fails at runtime if field is renamed
root.get("balance")

// With metamodel -- type-safe, fails at compile time if field is renamed
root.get(Account_.balance)

How is the Criteria API different from JPQL?

This comparison table captures the key trade-offs.

| Feature | Criteria API | JPQL |
|---|---|---|
| Query style | Programmatic (objects) | Declarative (string) |
| Type safety | Yes (with metamodel) | No (string, fails at runtime) |
| Dynamic queries | Excellent -- add predicates conditionally | Painful -- string concatenation |
| Readability | Verbose, harder to read | Concise, easy to read |
| IDE support | Auto-complete with metamodel | Limited (string) |
| Refactoring | Rename field, build breaks (metamodel) | Rename field, query silently breaks |
| Learning curve | Steeper | Familiar SQL-like syntax |
| Use case | Dynamic filters, search screens | Fixed queries, reports |

JAVA
// JPQL -- readable but fragile for dynamic queries
@Query("SELECT a FROM Account a WHERE a.status = :status AND a.balance > :min")
List<Account> findActiveHighBalance(
    @Param("status") String status,
    @Param("min") BigDecimal min);
// What if status is optional? Need a separate query or ugly COALESCE trick

// Criteria/Specification -- verbose but dynamic and safe
Specification<Account> spec = Specification
    .where(AccountSpecs.hasStatus(status))   // skipped if null
    .and(AccountSpecs.balanceAtLeast(min));   // skipped if null
repo.findAll(spec);
// Handles any combination of optional filters cleanly

What are the rules and constraints?

  • The repository must extend JpaSpecificationExecutor to use findAll(Specification).
  • A specification returning null means "no filter" -- it is skipped during composition.
  • Specifications compose with and(), or(), and not() -- these are static methods on Specification.
  • Criteria queries support projections into DTOs using cb.construct().
  • Criteria queries support joins using root.join() and fetch joins using root.fetch().
  • The metamodel generator must be configured in the build tool to get compile-time safety.
JAVA
// DTO projection with Criteria API
package com.bank.dto;

import java.math.BigDecimal;

public class AccountSummary {
    private final String accountId;
    private final BigDecimal balance;

    public AccountSummary(String accountId, BigDecimal balance) {
        this.accountId = accountId;
        this.balance = balance;
    }

    // getters
}
JAVA
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<AccountSummary> cq = cb.createQuery(AccountSummary.class);
Root<Account> root = cq.from(Account.class);

cq.select(cb.construct(AccountSummary.class,
    root.get("accountId"),
    root.get("balance")));
cq.where(cb.equal(root.get("status"), "ACTIVE"));

List<AccountSummary> summaries = em.createQuery(cq).getResultList();
TEXT
Expected output:
[AccountSummary{SAV2001, 500000.00}, AccountSummary{CUR3001, 120000.00}]

What are common mistakes?

Mistake 1: Building dynamic JPQL with string concatenation.

JAVA
// WRONG -- fragile, injection-risky, hard to maintain
public List<Account> search(String type, BigDecimal min) {
    String jpql = "SELECT a FROM Account a WHERE 1=1";
    if (type != null) {
        jpql += " AND a.accountType = '" + type + "'"; // SQL INJECTION!
    }
    if (min != null) {
        jpql += " AND a.balance >= " + min;
    }
    return em.createQuery(jpql, Account.class).getResultList();
}
JAVA
// CORRECT -- use Specification with parameterized predicates
Specification<Account> spec = Specification
    .where(AccountSpecs.hasType(type))
    .and(AccountSpecs.balanceAtLeast(min));
return repo.findAll(spec);
// Parameters are bound safely, no injection risk

Mistake 2: Using string field names instead of the metamodel.

JAVA
// WRONG -- string "balance" is not checked at compile time
root.get("balance"); // if someone renames balance to accountBalance,
                      // this fails at RUNTIME, not at compile time

// CORRECT -- use the metamodel for compile-time safety
root.get(Account_.balance); // if field is renamed, build breaks immediately

Mistake 3: Fetch joins inside specifications breaking pagination.

JAVA
// WRONG -- fetch join with pagination causes Hibernate to load
// ALL rows into memory and paginate in-memory (HHH000104 warning)
public static Specification<Account> withTransactions() {
    return (root, query, cb) -> {
        root.fetch("transactions", JoinType.LEFT); // FETCH JOIN
        return null;
    };
}
repo.findAll(spec, PageRequest.of(0, 10)); // in-memory pagination!
JAVA
// CORRECT -- use fetch join only for non-paginated queries,
// or use a regular join and a separate query for the collection
public static Specification<Account> withTransactions() {
    return (root, query, cb) -> {
        // Only fetch when the query is not a count query (pagination)
        if (query.getResultType() != Long.class) {
            root.fetch("transactions", JoinType.LEFT);
        }
        return null;
    };
}

What about joins and fetch joins in Criteria?

The Criteria API supports joins for filtering on related entities and fetch joins for eager loading.

JAVA
package com.bank.spec;

import com.bank.model.Account;
import com.bank.model.Transaction;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;

public class AccountSpecs {

    // Join: filter accounts that have a transaction above a threshold
    public static Specification<Account> hasTransactionAbove(BigDecimal threshold) {
        return (root, query, cb) -> {
            if (threshold == null) return null;
            Join<Account, Transaction> txn = root.join("transactions", JoinType.INNER);
            query.distinct(true); // avoid duplicates from the join
            return cb.greaterThan(txn.get("amount"), threshold);
        };
    }
}
JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import com.bank.spec.AccountSpecs;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;
import java.util.List;

public class TransactionSearchService {

    private final AccountRepository repo;

    public TransactionSearchService(AccountRepository repo) { this.repo = repo; }

    public List<Account> findAccountsWithLargeTransactions() {
        Specification<Account> spec = Specification
            .where(AccountSpecs.hasStatus("ACTIVE"))
            .and(AccountSpecs.hasTransactionAbove(new BigDecimal("100000.00")));

        return repo.findAll(spec);
    }
}
TEXT
Expected output:
[Account{SAV2001, Priya, 500000.00}]
// Only accounts with at least one transaction above 100,000

When should you use Criteria / Specification?

Use Criteria or Specification when:

  • The query has optional filters that change at runtime.
  • You need type-safe queries with metamodel support.
  • You want composable, reusable query fragments.
  • The search screen has multiple optional parameters.

Do NOT use Criteria or Specification when:

  • The query is fixed and known at compile time. Use JPQL or @NamedQuery instead.
  • Readability is the priority. JPQL is much easier to read than Criteria code.
  • The query is simple enough for a derived query method like findByStatusAndAccountType().

At National Bank of India, Pankaj's rule is: fixed queries use @Query or @NamedQuery for readability, and dynamic search screens use Specification for flexibility. He never mixes the two for the same use case.

JAVA
// Fixed query -- use @Query for readability
@Query("SELECT a FROM Account a WHERE a.status = 'ACTIVE' ORDER BY a.balance DESC")
List<Account> findAllActive();

// Dynamic search -- use Specification for flexibility
Specification<Account> spec = Specification
    .where(AccountSpecs.hasType(request.getType()))
    .and(AccountSpecs.balanceAtLeast(request.getMinBalance()))
    .and(AccountSpecs.hasStatus(request.getStatus()))
    .and(AccountSpecs.holderNameContains(request.getName()));
repo.findAll(spec, request.getPageable());

What should a senior engineer know?

  • Specification testing. Each specification is a pure function (root, query, cb -> predicate), so you can test it in isolation with a mocked CriteriaBuilder. In practice, integration tests with an in-memory database are more reliable.
  • Specification with pagination. JpaSpecificationExecutor.findAll(Specification, Pageable) returns a Page. Watch for the HHH000104 warning if you combine fetch joins with pagination.
  • Complex boolean logic. Specifications support nested boolean expressions: Specification.where(a.or(b)).and(c.or(d)). This maps to (A OR B) AND (C OR D) in SQL.
  • Criteria for aggregation. Use cb.count(), cb.sum(), cb.avg() with cq.groupBy() for aggregate queries. Return results as a DTO using cb.construct().
  • Performance. Criteria queries are compiled to the same SQL as equivalent JPQL. There is no performance difference at runtime. The overhead is in the verbosity of building the query in Java.
  • Alternative: Querydsl. Some teams prefer Querydsl as a more readable alternative to the Criteria API. Querydsl generates Q-classes (similar to the metamodel) and provides a fluent API.
JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import com.bank.spec.AccountSpecs;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;

public class AccountPaginatedSearchService {

    private final AccountRepository repo;

    public AccountPaginatedSearchService(AccountRepository repo) {
        this.repo = repo;
    }

    public Page<Account> search(
            String type, BigDecimal minBalance, int page, int size) {

        Specification<Account> spec = Specification
            .where(AccountSpecs.hasType(type))
            .and(AccountSpecs.balanceAtLeast(minBalance))
            .and(AccountSpecs.hasStatus("ACTIVE"));

        PageRequest pageable = PageRequest.of(page, size,
            Sort.by(Sort.Direction.DESC, "balance"));

        return repo.findAll(spec, pageable);
    }
}
TEXT
Expected output (type="SAVINGS", minBalance=10000, page=0, size=10):
Page 0 of 1, total 3 elements:
[Account{SAV2001, Priya, 500000.00},
 Account{SAV2003, Rajesh, 150000.00},
 Account{SAV2005, Pankaj, 75000.00}]

Visual Overview

DIAGRAM
  Criteria API Object Tree:

  CriteriaQuery<Account>
  ├── SELECT: root (Account)
  ├── FROM:   Account
  ├── WHERE:
  │     AND
  │     ├── equal(accountType, "SAVINGS")
  │     ├── ge(balance, 50000)
  │     └── equal(status, "ACTIVE")
  └── ORDER BY: desc(balance)
       │
       │ Hibernate translates
       ▼
  SELECT * FROM accounts
  WHERE account_type = 'SAVINGS'
    AND balance >= 50000
    AND status = 'ACTIVE'
  ORDER BY balance DESC
DIAGRAM
  Specification Composition:

  hasType("SAVINGS")    → Predicate: account_type = 'SAVINGS'
  balanceAtLeast(50000) → Predicate: balance >= 50000
  hasStatus(null)       → null (SKIPPED)
  holderNameContains(null) → null (SKIPPED)
       │
       │ Specification.where(a).and(b).and(null).and(null)
       ▼
  Final WHERE: account_type = 'SAVINGS' AND balance >= 50000
  (null specifications are automatically excluded)
DIAGRAM
  Repository + Specification Flow:

  Controller → AccountService
                    │
                    ▼
              Build Specification
              (compose optional filters)
                    │
                    ▼
              AccountRepository.findAll(spec, pageable)
              (extends JpaSpecificationExecutor)
                    │
                    ▼
              Hibernate generates SQL
              with only active predicates
                    │
                    ▼
              Page<Account> result

Key Takeaway

The Criteria API builds queries programmatically as a tree of objects instead of as a string. CriteriaBuilder is the factory, CriteriaQuery is the query, Root is the entity, and Predicate is a condition. This gives you type safety, because the query is checked by the compiler when you use the generated metamodel, and dynamic construction, because you add only the conditions that apply at runtime. Spring Data's Specification wraps this pattern into a composable interface where each specification contributes one predicate, and the key trick is that returning null means "skip this filter."

The problem this solves is optional filters. A search screen may have a dozen filters and the user might fill in none, some, or all. Building that with string concatenation is fragile, error-prone, and risks SQL injection. With specifications, you write one small reusable specification per filter, compose them unconditionally with and/or, and the absent ones drop out automatically. The repository's findAll(Specification, Pageable) handles pagination and sorting.

At National Bank of India, Pankaj's rule is clear: fixed queries use @Query or @NamedQuery because they are readable and concise, and dynamic search screens use Specification because they handle any combination of optional filters cleanly and safely. Each specification is independently testable, reusable across services, and composes into complex boolean logic without string manipulation.

Remember one sentence: the Criteria API builds queries as type-safe objects for dynamic filters, and Specification makes them composable so null inputs are automatically skipped.

---

Share
Use ← → arrow keys to navigate