Dynamic filters with JPA Specifications, explained simply
Most of us have written search endpoints where we concatenate query predicates as strings or build Criteria objects by hand. That approach gets unwieldy fast: you're mixing business logic with SQL fragments, and it's easy to introduce bugs when filters interact. Dynamic filters with JPA Specifications offer a cleaner path, letting you build composable, type-safe predicates that your repository can chain together based on what the user actually sent.
Why string concatenation is a trap
When you start building a search endpoint, the temptation is to write something like this:
public List<Product> search(String name, BigDecimal maxPrice, String category) {
String query = "SELECT p FROM Product p WHERE 1=1";
if (name != null) {
query += " AND p.name LIKE '%" + name + "%'";
}
if (maxPrice != null) {
query += " AND p.price < " + maxPrice;
}
if (category != null) {
query += " AND p.category = '" + category + "'";
}
// ... execute raw query
}
This pattern breaks for three reasons: SQL injection risk (even with Hibernate), readability that decays with each new filter, and logic spread across string manipulation instead of being expressed in actual code. You're not testing the query structure—you're testing strings. Specifications solve this by moving filter logic into reusable, composable predicates.
One spec per filter — Small, composable predicates
A Specification in Spring Data JPA is a simple functional interface. It takes a root entity, a query object, and a criteria builder, then returns a Predicate. The key insight is to write one static method per filter, each returning a Specification that encapsulates a single piece of logic.
Here's what that looks like for a Product entity:
public class ProductSpecifications {
public static Specification<Product> priceBelow(BigDecimal max) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.lessThan(root.get("price"), max);
}
public static Specification<Product> nameLike(String name) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.like(
criteriaBuilder.lower(root.get("name")),
"%" + name.toLowerCase() + "%"
);
}
public static Specification<Product> inCategory(String category) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("category"), category);
}
public static Specification<Product> createdAfter(LocalDate date) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), date);
}
}
Each method is a building block. The lambda receives the root (your Product entity in table form), the query metadata, and the criteria builder—the tool that lets you construct SQL predicates in a type-safe way. When you call root.get("price"), you're accessing the entity field directly; if you refactor the field name, your IDE will catch it. No strings floating around.
Combine them — Only apply filters the user actually sent
The power of Specifications is composition. Spring Data gives you Specification.where(), which acts as an entry point, and then .and() and .or() to chain predicates. The trick is to only add a Specification if the corresponding filter was provided by the request.
Consider a search endpoint that accepts optional parameters:
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping("/search")
public Page<Product> search(
@RequestParam(required = false) String name,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) String category,
@RequestParam(required = false) String createdAfter,
Pageable pageable) {
Specification<Product> spec = Specification.where(null);
if (name != null && !name.isBlank()) {
spec = spec.and(ProductSpecifications.nameLike(name));
}
if (maxPrice != null) {
spec = spec.and(ProductSpecifications.priceBelow(maxPrice));
}
if (category != null && !category.isBlank()) {
spec = spec.and(ProductSpecifications.inCategory(category));
}
if (createdAfter != null) {
spec = spec.and(ProductSpecifications.createdAfter(LocalDate.parse(createdAfter)));
}
return productRepository.findAll(spec, pageable);
}
}
Notice that we start with Specification.where(null), which creates a no-op Specification. Then we conditionally chain each filter using .and(). If no filters are provided, Hibernate generates a simple SELECT * FROM product. If all are provided, Hibernate generates the full WHERE clause with all conditions joined by AND.
The benefit is separation of concerns: the filter logic lives in ProductSpecifications, the composition logic lives in the controller, and the database execution is left to Spring Data. You can test each Specification independently, and you can add new filters without touching the controller logic.
Extend the repository — JpaSpecificationExecutor adds findAll
To use Specifications, your repository needs to extend JpaSpecificationExecutor in addition to JpaRepository:
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
That one line gives you three new methods:
findAll(Specification<T> spec)— Returns a List.findAll(Specification<T> spec, Pageable pageable)— Returns a Page (what we used above).findOne(Specification<T> spec)— Returns an Optional of a single result.
You don't override these methods; Spring Data provides them for free. Under the hood, it uses the Specification's toPredicate() method to build the WHERE clause and passes it to Hibernate.
Real-world example: E-commerce listing with facets
Imagine you're building a product listing page that allows users to filter by price range, availability, brand, and discount. Without Specifications, you'd end up passing boolean flags or building query strings. With Specifications, your code stays clean:
public class ProductSpecifications {
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> cb.and(
cb.greaterThanOrEqualTo(root.get("price"), min),
cb.lessThanOrEqualTo(root.get("price"), max)
);
}
public static Specification<Product> isAvailable(boolean available) {
return (root, query, cb) ->
cb.equal(root.get("available"), available);
}
public static Specification<Product> brandIn(List<String> brands) {
return (root, query, cb) ->
root.get("brand").in(brands);
}
public static Specification<Product> hasDiscount(boolean withDiscount) {
return (root, query, cb) -> {
if (withDiscount) {
return cb.greaterThan(root.get("discountPercent"), 0);
}
return null; // No filter applied
};
}
}
Then in your controller, you build the spec conditionally based on what came in the request. The same Specifications work with pagination, sorting, and counting. If you need to know how many products match before filtering by page, productRepository.count(spec) works too.
When to consider Querydsl or jOOQ
Specifications are excellent for typical CRUD filters: price, date range, category, keyword search. But if you're building a complex reporting query with multiple joins, subqueries, or window functions, Specifications can become hard to read. At that point, it's worth considering Querydsl or jOOQ.
Querydsl is type-safe and integrates with Spring Data. jOOQ is a typed SQL DSL that keeps you closer to actual SQL syntax. Both avoid string concatenation while staying readable for intricate queries. For example, a reporting query that aggregates sales by region and month might be clearer in jOOQ than in nested Specifications.
The rule of thumb: use Specifications for search filters, use Querydsl or jOOQ for reporting and complex analytics. They're not mutually exclusive—many teams use Specifications in their main repository and Querydsl for a separate analytics repository.
Common mistakes
- Forgetting to null-check user input: If a user sends an empty string or a null value, your Specification should handle it gracefully. In the example above, we check
!name.isBlank()before calling the Specification. Don't let invalid input reach your predicates. - Returning null from a Specification: If you need a Specification that does nothing, return a no-op predicate (like
cb.conjunction()orcb.literal(true)) instead of null. Returning null can cause NullPointerException when combined with other Specifications. - Not indexing filtered columns: A perfectly composed Specification can still run slowly if the database isn't indexed. If you filter by price or category frequently, make sure those columns are indexed. Specifications generate efficient SQL, but the database still needs to find the rows fast.
- Ignoring SQL injection with user input: Even though Specifications use prepared statements, be careful when building dynamic strings inside predicates. In the
nameLikeexample, we explicitly usecriteriaBuilder.like(), which binds the value safely. Don't concatenate user input into field names or table names. - Over-composing Specifications: If you end up with 20 different Specification methods for the same entity, you might be mixing filter logic with business logic. Keep Specifications focused on simple predicates, and let your service layer handle business rules.
Key takeaways
- Write one Specification per filter, keeping each predicate small and testable. This makes your code modular and reusable.
- Compose Specifications in your controller using
Specification.where(null).and(...).and(...), only adding filters that the user actually sent. This avoids unnecessary WHERE clauses and keeps the query lean. - Extend
JpaSpecificationExecutorin your repository to unlockfindAll(spec, pageable)and friends, giving you pagination and sorting out of the box. - For simple search filters, Specifications are clearer and safer than string concatenation. For complex reporting queries, consider Querydsl or jOOQ to keep readability high.
- Always validate and null-check user input before passing it to a Specification, and ensure your database columns are indexed if they're frequently filtered.






Comments
Loading comments…