Search forms often have conditions that may or may not be filled in. Sometimes only the name is specified with category left empty, other times the price range is also fully populated. Trying to express such variable conditions with static JPQL via @Query quickly hits a wall.
But if you start piling up if statements in the service layer, it tends to look like this:
if (name != null && category != null) {
return repo.findByNameAndCategory(name, category);
} else if (name != null) {
return repo.findByName(name);
} else if (category != null) {
return repo.findByCategory(category);
} else {
return repo.findAll();
}
With three conditions, you get 8 branches. Let’s solve this with the Specification pattern.
Extend JpaSpecificationExecutor on the Repository
Just add JpaSpecificationExecutor to your existing JpaRepository.
public interface ProductRepository
extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
}
This makes findAll(Specification<T> spec) and findAll(Specification<T> spec, Pageable pageable) available. The entity is assumed to look like this:
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
private String category;
// getters/setters omitted
}
Grasp the Three Elements of the Criteria API
To implement toPredicate of a Specification, three objects come into play.
- Root<T>: the entity in the FROM clause. Reference columns with
root.get("name"). - CriteriaBuilder: the factory for generating WHERE conditions (Predicates).
- Predicate: the actual condition expression. Build them with
cb.equal(...),cb.like(...), etc.
You use these three to write conditions inside toPredicate.
Writing a Single-Condition Specification
Implementing it as a lambda expression is the simplest approach.
public class ProductSpecifications {
public static Specification<Product> nameContains(String name) {
return (root, query, cb) ->
cb.like(root.get("name"), "%" + name + "%");
}
public static Specification<Product> categoryEquals(String category) {
return (root, query, cb) ->
cb.equal(root.get("category"), category);
}
}
Gathering them as static methods in a utility class keeps the call site clean.
Safely Skip null and Empty Strings
When a search condition is not provided, return cb.conjunction(). Since it is a Predicate that is always true, it effectively skips that WHERE condition.
public static Specification<Product> nameContains(String name) {
return (root, query, cb) -> {
if (name == null || name.isEmpty()) {
return cb.conjunction();
}
return cb.like(root.get("name"), "%" + name + "%");
};
}
Passing null straight into cb.like(...) causes a NullPointerException, so make a habit of putting this check at the top of every method.
Chain Multiple Conditions with AND/OR
Multiple Specifications can be chained with Specification.where().and().or().
Specification<Product> spec = Specification
.where(ProductSpecifications.nameContains(form.getName()))
.and(ProductSpecifications.categoryEquals(form.getCategory()))
.and(ProductSpecifications.priceBetween(form.getMinPrice(), form.getMaxPrice()));
where() just creates a starting point, so there’s no problem even if the first Specification you pass returns null. Use .or(spec) when you want to combine with OR.
A Utility Class That Builds a Specification from a Form Bean
In practice, it’s common to accept a Bean that bundles the search conditions and convert it into a Specification.
@Getter @Setter
public class ProductSearchForm {
private String name;
private String category;
private BigDecimal minPrice;
private BigDecimal maxPrice;
}
public class ProductSpecifications {
public static Specification<Product> from(ProductSearchForm form) {
return Specification
.where(nameContains(form.getName()))
.and(categoryEquals(form.getCategory()))
.and(priceBetween(form.getMinPrice(), form.getMaxPrice()));
}
private static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> {
if (min == null && max == null) return cb.conjunction();
if (min == null) return cb.lessThanOrEqualTo(root.<BigDecimal>get("price"), max);
if (max == null) return cb.greaterThanOrEqualTo(root.<BigDecimal>get("price"), min);
return cb.between(root.<BigDecimal>get("price"), min, max);
};
}
// nameContains and categoryEquals are as shown above
}
The service layer just calls from(form), and the cascade of if statements disappears.
Combining with Pagination
Filtering on Related Entities with JOIN
Consider a case where Product references a Category entity and you want to filter by category name. Join with root.join("category") and reference columns on the joined side.
public static Specification<Product> categoryNameEquals(String categoryName) {
return (root, query, cb) -> {
if (categoryName == null || categoryName.isEmpty()) {
return cb.conjunction();
}
Join<Product, Category> category = root.join("category", JoinType.LEFT);
return cb.equal(category.get("name"), categoryName);
};
}
Using LEFT JOIN keeps Product rows with no category in the results. Collection joins tend to produce duplicates, so combine it with distinct (described below).
If you also want fetch join to suppress N+1 queries, see “How to Solve the N+1 Problem in Spring Data JPA”.
Remove Duplicate Rows from Joins with distinct
A one-to-many JOIN may return the same parent record multiple times. Calling query.distinct(true) inside toPredicate adds SELECT DISTINCT.
public static Specification<Product> hasTag(String tag) {
return (root, query, cb) -> {
if (tag == null || tag.isEmpty()) return cb.conjunction();
query.distinct(true);
Join<Product, Tag> tags = root.join("tags");
return cb.equal(tags.get("name"), tag);
};
}
Filtering Multiple Values with an IN Clause
For multi-select forms like checkboxes, use root.get(...).in(values).
public static Specification<Product> categoryIn(List<String> categories) {
return (root, query, cb) -> {
if (categories == null || categories.isEmpty()) {
return cb.conjunction();
}
return root.get("category").in(categories);
};
}
Expressing NOT Conditions and Negations
You can build a negation with Specification.not(spec) or cb.not(predicate). Handy for exclusion filters (e.g., dropping discontinued categories).
Specification<Product> spec = Specification
.where(ProductSpecifications.categoryEquals("book"))
.and(Specification.not(ProductSpecifications.nameContains("used")));
Combining Dynamic Sorting with Specifications
Sort conditions are typically passed via Sort on Pageable, but you can also specify query.orderBy(...) inside the Specification.
Pageable pageable = PageRequest.of(0, 20, Sort.by("price").descending());
Page<Product> page = productRepository.findAll(ProductSpecifications.from(form), pageable);
Since the request parameter ?sort=price,desc can switch the sort column dynamically, you rarely need to write orderBy inside a Specification.
Writing Unit Tests for Specifications
It’s hard to verify a Specification by testing toPredicate in isolation without going through a Repository, so a practical approach is to run findAll(spec) against H2 with @DataJpaTest.
@DataJpaTest
class ProductSpecificationsTest {
@Autowired ProductRepository repository;
@Test
void nameContains_matchesPartial() {
repository.save(new Product(null, "Spring Boot Introduction", BigDecimal.valueOf(2000), "book"));
repository.save(new Product(null, "Java in Practice", BigDecimal.valueOf(2500), "book"));
List<Product> result = repository.findAll(ProductSpecifications.nameContains("Spring"));
assertThat(result).hasSize(1);
}
}
This is preferable to testing the query builder layer directly, because you can also confirm that the expected SQL is actually issued.
With findAll(spec, pageable), you can combine a Specification with pagination as is.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> search(ProductSearchForm form, Pageable pageable) {
return productRepository.findAll(ProductSpecifications.from(form), pageable);
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
@GetMapping("/search")
public Page<Product> search(ProductSearchForm form, Pageable pageable) {
return productService.search(form, pageable);
}
}
Spring automatically binds query parameters like ?page=0&size=10&sort=name,asc to Pageable. For pagination in general, take a look at “How to Implement Pagination in a Spring Boot REST API” as well.
Choosing Between This and QueryDSL
Specifications can be used without any additional libraries, so they’re sufficient for simple search forms. When joins get complex or you want type-safe queries with code completion, QueryDSL has the edge, but it comes with code-generation setup costs. If you’re at the stage of “I just want to clean up my dynamic queries first,” starting with Specifications is the safer bet.
Summary
Introducing the Specification pattern frees you from if-statement hell in the service layer.
- Enable it just by extending
JpaSpecificationExecutor - Safely skip null conditions with
cb.conjunction() - Express multiple conditions by chaining
where().and().or() - Bundling the conversion from a form Bean to a Specification in a utility class keeps call sites simple
findAll(spec, pageable)works with pagination out of the box
For the basics of query methods, see “Spring Data JPA Query Methods Guide”, and for entity relationship design, take a look at “Spring Boot JPA Entity Relationship Design” as well.
Frequently Asked Questions (FAQ)
Q. How should I choose between Specification and @Query?
A. When the search conditions are fixed and you want to write complex SQL, @Query is more readable. On the other hand, for cases like form input where the presence of conditions changes dynamically, Specifications are a better fit. The two can coexist in the same Repository.
Q. What’s the difference between Specification and QueryDSL?
A. Specifications are included by default in Spring Data JPA and require no additional libraries. QueryDSL uses code generation to let you reference column names as types rather than strings, which enables auto-completion, but it requires build configuration. For simple search forms, Specifications are commonly chosen; for admin screens with many joins and aggregations, QueryDSL is often preferred.
Q. How do I check the SQL that a Specification issues?
A. Setting spring.jpa.show-sql: true and spring.jpa.properties.hibernate.format_sql: true in application.yml outputs the SQL that Hibernate assembles in a formatted form. In production, it’s common to set the org.hibernate.SQL logger to DEBUG and route it to the application log.
Q. Why return cb.conjunction() in null checks?
A. Returning null partway through a Specification chain causes NullPointerExceptions or unexpected behavior on subsequent .and() calls. Since cb.conjunction() is an always-true Predicate, it effectively “ignores that condition” when AND-combined, allowing you to skip conditions safely.
Q. Does extending JpaSpecificationExecutor affect existing query methods?
A. No. Just extend it in parallel with JpaRepository, and existing query methods like findByName remain usable as-is. You’re simply adding the option of search via Specifications.