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 theFROMclause)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
| Scenario | Best approach |
|---|---|
| Fixed query, no dynamic conditions | Derived method or @Query |
| Optional filters on one entity | Specifications |
| Complex multi-entity aggregation | @Query JPQL / native SQL |
| Reporting / analytics | Native SQL or QueryDSL |
| Cross-cutting search across entities | Elasticsearch / 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>addsfindAll(Specification, Pageable)and other methods to your repository.- A
Specification<T>is a function returning a JPAPredicate. Returnnullfor “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
@Queryfor fixed, complex queries.
Next: Article 22 covers @Transactional in depth — propagation levels, isolation levels, rollback rules, and the read-only optimization.