Specifications and the Criteria API: Dynamic Queries

Why Specifications

Derived query methods and @Query annotations work for fixed queries. But what happens when the user can filter by any combination of 10 fields — some optional, some not?

// Wrong approach — combinatorial explosion
List<Product> findByName(String name);
List<Product> findByNameAndPrice(String name, BigDecimal price);
List<Product> findByNameAndPriceAndCategory(...);
// ... you'd need 2^10 = 1024 methods

The Specification pattern solves this. Each filter condition is a reusable Specification<T> object. You compose them with and(), or(), and not() at runtime, and Spring Data JPA generates the correct query.


Setup

Enable specification support by extending JpaSpecificationExecutor:

@Repository
public interface ProductRepository
    extends JpaRepository<Product, Long>,
            JpaSpecificationExecutor<Product> {
}

JpaSpecificationExecutor adds:

Optional<T> findOne(Specification<T> spec);
List<T> findAll(Specification<T> spec);
List<T> findAll(Specification<T> spec, Sort sort);
Page<T> findAll(Specification<T> spec, Pageable pageable);
long count(Specification<T> spec);
boolean exists(Specification<T> spec);

The Specification Interface

@FunctionalInterface
public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
  • Root<T> — the entity being queried (like the FROM clause)
  • CriteriaQuery<?> — the query being built (allows joins, ordering)
  • CriteriaBuilder — factory for predicates and expressions

Writing Your First Specification

public class ProductSpecs {

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

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

    public static Specification<Product> hasMaxPrice(BigDecimal max) {
        return (root, query, cb) ->
            max == null ? null : cb.lessThanOrEqualTo(root.get("price"), max);
    }

    public static Specification<Product> isActive() {
        return (root, query, cb) -> cb.isTrue(root.get("active"));
    }

    public static Specification<Product> inCategory(Long categoryId) {
        return (root, query, cb) ->
            categoryId == null ? null : cb.equal(root.get("category").get("id"), categoryId);
    }
}

Returning null from toPredicate means “no restriction” — Specification treats null as an always-true predicate, so optional filters compose naturally.


Composing Specifications

@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;

    public Page<Product> search(ProductFilter filter, Pageable pageable) {
        Specification<Product> spec = Specification.where(isActive())
            .and(hasName(filter.getName()))
            .and(hasMinPrice(filter.getMinPrice()))
            .and(hasMaxPrice(filter.getMaxPrice()))
            .and(inCategory(filter.getCategoryId()));

        return productRepository.findAll(spec, pageable);
    }
}

Specification.where() starts a chain. .and() and .or() compose with standard boolean logic. Non-null parameters add predicates; null parameters are skipped (returning null from the spec means no predicate added).

// Combine with OR
Specification<Product> discountedOrNew =
    hasDiscount().or(isNewArrival());

// Negate
Specification<Product> notDiscounted = hasDiscount().not();

Joins in Specifications

Access related entities through root.join():

public static Specification<Product> hasTag(String tagName) {
    return (root, query, cb) -> {
        if (tagName == null) return null;
        // Join Product → tags (ManyToMany)
        Join<Product, Tag> tagJoin = root.join("tags", JoinType.INNER);
        return cb.equal(cb.lower(tagJoin.get("name")), tagName.toLowerCase());
    };
}

public static Specification<Product> inCategoryBySlug(String slug) {
    return (root, query, cb) -> {
        if (slug == null) return null;
        Join<Product, Category> categoryJoin = root.join("category", JoinType.INNER);
        return cb.equal(categoryJoin.get("slug"), slug);
    };
}

Avoiding Duplicate Results with Joins

When joining a collection (one-to-many or many-to-many), the JOIN can produce duplicate rows. Use query.distinct(true):

public static Specification<Product> hasTag(String tagName) {
    return (root, query, cb) -> {
        if (tagName == null) return null;
        query.distinct(true); // prevent duplicate Products
        Join<Product, Tag> tagJoin = root.join("tags", JoinType.INNER);
        return cb.equal(cb.lower(tagJoin.get("name")), tagName.toLowerCase());
    };
}

Type-Safe Metamodel

String-based field access (root.get("name")) is fragile — a typo compiles but fails at runtime. The JPA Static Metamodel Generator creates type-safe classes:

<!-- pom.xml -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
    <scope>provided</scope>
</dependency>

The processor generates Product_ at compile time:

// Generated: Product_.java
@StaticMetamodel(Product.class)
public class Product_ {
    public static volatile SingularAttribute<Product, Long> id;
    public static volatile SingularAttribute<Product, String> name;
    public static volatile SingularAttribute<Product, BigDecimal> price;
    public static volatile SingularAttribute<Product, Boolean> active;
    public static volatile SingularAttribute<Product, Category> category;
    public static volatile SetAttribute<Product, Tag> tags;
}

Use it in specifications:

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

public static Specification<Product> hasMinPrice(BigDecimal min) {
    return (root, query, cb) ->
        min == null ? null
            : cb.greaterThanOrEqualTo(root.get(Product_.price), min);
}

Now Product_.name is a compile-time reference — renaming the field catches the error immediately.


Complete Search Example

public record ProductFilter(
    String keyword,
    Long categoryId,
    String tagName,
    BigDecimal minPrice,
    BigDecimal maxPrice,
    Boolean inStock
) {}
public class ProductSpecs {

    public static Specification<Product> active() {
        return (root, query, cb) -> cb.isTrue(root.get(Product_.active));
    }

    public static Specification<Product> keywordMatches(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) return null;
            String pattern = "%" + keyword.toLowerCase() + "%";
            return cb.or(
                cb.like(cb.lower(root.get(Product_.name)), pattern),
                cb.like(cb.lower(root.get(Product_.description)), pattern),
                cb.like(cb.lower(root.get(Product_.sku)), pattern)
            );
        };
    }

    public static Specification<Product> inCategory(Long categoryId) {
        return (root, query, cb) ->
            categoryId == null ? null
                : cb.equal(root.get(Product_.category).get(Category_.id), categoryId);
    }

    public static Specification<Product> hasTag(String tagName) {
        return (root, query, cb) -> {
            if (tagName == null) return null;
            query.distinct(true);
            Join<Product, Tag> tags = root.join(Product_.tags, JoinType.INNER);
            return cb.equal(cb.lower(tags.get(Tag_.name)), tagName.toLowerCase());
        };
    }

    public static Specification<Product> priceAtLeast(BigDecimal min) {
        return (root, query, cb) ->
            min == null ? null : cb.greaterThanOrEqualTo(root.get(Product_.price), min);
    }

    public static Specification<Product> priceAtMost(BigDecimal max) {
        return (root, query, cb) ->
            max == null ? null : cb.lessThanOrEqualTo(root.get(Product_.price), max);
    }

    public static Specification<Product> inStock() {
        return (root, query, cb) -> cb.greaterThan(root.get(Product_.stockQuantity), 0);
    }
}
@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;

    public Page<Product> search(ProductFilter filter, Pageable pageable) {
        Specification<Product> spec = Specification.where(active())
            .and(keywordMatches(filter.keyword()))
            .and(inCategory(filter.categoryId()))
            .and(hasTag(filter.tagName()))
            .and(priceAtLeast(filter.minPrice()))
            .and(priceAtMost(filter.maxPrice()));

        if (Boolean.TRUE.equals(filter.inStock())) {
            spec = spec.and(inStock());
        }

        return productRepository.findAll(spec, pageable);
    }
}

Organising Specification Classes

For large projects, keep specifications close to their entity:

com.example.product/
  Product.java
  Product_.java            ← generated metamodel
  ProductRepository.java
  ProductSpecs.java        ← all Product specifications
  ProductSearchService.java

Or use inner classes in the repository:

public interface ProductRepository
    extends JpaRepository<Product, Long>,
            JpaSpecificationExecutor<Product> {

    final class Specs {
        public static Specification<Product> active() { ... }
        public static Specification<Product> hasName(String name) { ... }
    }
}

// Usage
productRepository.findAll(ProductRepository.Specs.active().and(Specs.hasName("phone")), pageable);

When to Use Specifications vs Alternatives

ScenarioBest approach
Fixed query, no dynamic conditionsDerived method or @Query
Optional filters on one entitySpecifications
Complex multi-entity aggregation@Query JPQL / native SQL
Reporting / analyticsNative SQL or QueryDSL
Cross-cutting search across entitiesElasticsearch / full-text search

Specifications shine for filter-heavy search screens where many combinations of optional parameters are possible. They keep each filter condition isolated, testable, and composable.


Testing Specifications

Specifications are easy to unit test in isolation using @DataJpaTest:

@DataJpaTest
class ProductSpecsTest {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.saveAll(List.of(
            product("Laptop", new BigDecimal("999.99"), true),
            product("Phone", new BigDecimal("599.99"), true),
            product("Tablet", new BigDecimal("399.99"), false)
        ));
    }

    @Test
    void hasName_filters_by_partial_name() {
        List<Product> result = productRepository.findAll(ProductSpecs.keywordMatches("lap"));
        assertThat(result).hasSize(1).extracting(Product::getName).containsExactly("Laptop");
    }

    @Test
    void active_excludes_inactive() {
        List<Product> result = productRepository.findAll(ProductSpecs.active());
        assertThat(result).hasSize(2).extracting(Product::getName)
            .containsExactlyInAnyOrder("Laptop", "Phone");
    }

    @Test
    void combined_specs_compose_correctly() {
        Specification<Product> spec = ProductSpecs.active()
            .and(ProductSpecs.priceAtMost(new BigDecimal("700.00")));
        List<Product> result = productRepository.findAll(spec);
        assertThat(result).hasSize(1).extracting(Product::getName).containsExactly("Phone");
    }
}

Summary

  • JpaSpecificationExecutor<T> adds findAll(Specification, Pageable) and other methods to your repository.
  • A Specification<T> is a function returning a JPA Predicate. Return null for “no restriction” on optional parameters.
  • Compose with Specification.where().and().or().not().
  • Use the JPA Static Metamodel Generator for type-safe field access (no runtime typos).
  • Add query.distinct(true) inside specifications that join collections to prevent duplicate results.
  • Specifications are ideal for dynamic filter screens; use @Query for fixed, complex queries.

Next: Article 22 covers @Transactional in depth — propagation levels, isolation levels, rollback rules, and the read-only optimization.