Search forms have conditions that may or may not be filled in. Sometimes only the name is specified and the category is left empty, and sometimes the price range is fully filled in as well. Trying to express that kind of variable set of conditions with static JPQL in @Query quickly hits a wall.
On the other hand, if you keep stacking if statements in the service layer, you end up with something 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, there are eight branches. Let’s solve this with the Specification pattern.
Extend JpaSpecificationExecutor in Your Repository
All you need to do is 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. Since it is simply extended alongside JpaRepository, existing query methods such as findByName are unaffected. You just gain the ability to write additional searches that use Specifications. 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;
// getter/setter省略
}
Understand the Three Elements of the Criteria API
Three objects come into play when implementing toPredicate of a Specification.
- Root<T>: The entity in the FROM clause. You reference a column with
root.get("name") - CriteriaBuilder: A factory that produces WHERE conditions (Predicates)
- Predicate: The actual condition expression. You create it with
cb.equal(...)orcb.like(...)
Using these three, you write your conditions inside toPredicate.
Write a Single-Condition Specification
Implementing it with 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);
}
}
Grouping them as static methods in a utility class keeps the calling side clean.
Safely Skip null and Empty Strings
When a search condition has not been entered, return cb.conjunction(). This is an “always true” Predicate, so the WHERE condition is effectively skipped.
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 a null straight into cb.like(...) results in a NullPointerException, so make it a rule to put this check at the top of every method. Returning null for the Specification itself is another option, but it tends to cause unexpected behavior in subsequent .and() calls. The safe choice is to consistently use cb.conjunction() without second-guessing.
Chain Multiple Conditions with AND/OR
Multiple Specifications are 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() only establishes the starting point, so there is no problem if the first Specification passed to it returns null. When you want an OR combination, use .or(spec).
A Utility Class That Builds a Specification from a Form Bean
In real-world projects, a common pattern is to receive 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, categoryEquals は前述のとおり
}
The service layer now only has to call from(form), and the chain of ifs disappears.
Combine with Pagination
Filter on Related Entities Using 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 the column on the joined entity.
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 a LEFT JOIN keeps Product rows with no category set in the result. Collection joins tend to produce duplicates, so combine this with distinct, described below.
If you also want to use 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
In a one-to-many JOIN, the same parent record can be returned multiple times. Specifying query.distinct(true) inside toPredicate adds SELECT DISTINCT to the query.
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);
};
}
Filter by Multiple Values with an in Clause
For multi-select forms such as 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);
};
}
Express NOT Conditions and Negations
You can build a negation with Specification.not(spec) or cb.not(predicate). This is handy for exclusion filters (for example, excluding a discontinued category).
Specification<Product> spec = Specification
.where(ProductSpecifications.categoryEquals("book"))
.and(Specification.not(ProductSpecifications.nameContains("中古")));
Combine Dynamic Sorting with Specifications
Sort conditions are normally passed via the Sort in Pageable, but you can also specify query.orderBy(...) on the Specification side.
Pageable pageable = PageRequest.of(0, 20, Sort.by("price").descending());
Page<Product> page = productRepository.findAll(ProductSpecifications.from(form), pageable);
Because the sort column can be switched dynamically with a request parameter such as ?sort=price,desc, there is rarely any need to write orderBy inside a Specification.
Write Unit Tests for Specifications
Verifying toPredicate on its own without going through the Repository is difficult, so the practical approach is to run findAll(spec) against H2 using @DataJpaTest.
@DataJpaTest
class ProductSpecificationsTest {
@Autowired ProductRepository repository;
@Test
void nameContains_部分一致でヒットする() {
repository.save(new Product(null, "Spring Boot入門", BigDecimal.valueOf(2000), "book"));
repository.save(new Product(null, "Java実践", BigDecimal.valueOf(2500), "book"));
List<Product> result = repository.findAll(ProductSpecifications.nameContains("Spring"));
assertThat(result).hasSize(1);
}
}
This is recommended over testing the query builder layer directly, because it also lets you confirm that the SQL is issued as expected. When you want to see the generated SQL, set spring.jpa.show-sql: true and spring.jpa.properties.hibernate.format_sql: true in application.yml, and the SQL assembled by Hibernate will be printed in a formatted form. In production, the usual practice is to set the org.hibernate.SQL logger to DEBUG and route it into the application log.
With findAll(spec, pageable) you can combine a Specification with pagination directly.
@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 such as ?page=0&size=10&sort=name,asc to Pageable. For pagination in general, see also “How to Implement Pagination in a Spring Boot REST API”.
Choosing Between Specification and QueryDSL
Specifications work without any additional libraries, so they are sufficient for simple search forms. When joins become complex or you want type-safe code with IDE completion, QueryDSL has the advantage, but it comes with the setup cost of code generation. If you are at the stage of “I just want to clean up my dynamic queries first,” starting with Specifications is the safe bet.
The same reasoning applies when choosing between Specification and @Query. When the search conditions are fixed and you want to write complex SQL, @Query is more readable, whereas a search form whose conditions vary dynamically is a better fit for Specifications. Both can coexist in the same Repository, so it is perfectly fine to pick one or the other per screen.
Summary
Introducing the Specification pattern frees you from if-statement hell in the service layer.
- It can be enabled simply by extending
JpaSpecificationExecutor - Safely skip null conditions with
cb.conjunction() - Express multiple conditions by chaining with
where().and().or() - Grouping the logic into a utility class that builds a Specification from a form Bean keeps calls simple
- Pagination works out of the box with
findAll(spec, pageable)
For the basics of query methods, see “Ever Been Unsure How to Write Spring Data JPA Query Methods?”, and for designing entity relationships, see also “Designing Entity Relationships in Spring Boot JPA”.