Back to Blog
spring-datajpaspring-bootjavadatabasequery

Spring Data Specifications: Build Dynamic Queries Without Query Proliferation (2026)

Adding one filter per repository method causes method explosion. Spring Data JPA Specifications let you compose dynamic queries from reusable predicates without writing new methods.

J

JOptimize Team

May 28, 2026· 8 min read

A search API with 8 optional filters needs 256 method combinations if you use one repository method per combination. findByStatusAndRegion(), findByStatusAndRegionAndCustomerId(), findByRegionAndDateBetween() — the list grows exponentially. Spring Data JPA Specifications solve this with composable, reusable query predicates.


The Problem: Repository Method Explosion

// For a search with 5 optional filters, you need up to 2^5 = 32 methods public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByStatus(OrderStatus status); List<Order> findByCustomerId(Long customerId); List<Order> findByStatusAndCustomerId(OrderStatus status, Long customerId); List<Order> findByStatusAndRegion(OrderStatus status, String region); List<Order> findByCustomerIdAndRegion(Long customerId, String region); List<Order> findByStatusAndCustomerIdAndRegion(OrderStatus s, Long c, String r); // ... 26 more combinations }

The Solution: JPA Specifications

public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {} // JpaSpecificationExecutor adds findAll(Specification, Pageable)
// Reusable, composable predicates public class OrderSpecifications { public static Specification<Order> hasStatus(OrderStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } public static Specification<Order> forCustomer(Long customerId) { return (root, query, cb) -> customerId == null ? null : cb.equal(root.get("customerId"), customerId); } public static Specification<Order> inRegion(String region) { return (root, query, cb) -> region == null ? null : cb.equal(root.get("region"), region); } public static Specification<Order> createdBetween(LocalDateTime from, LocalDateTime to) { return (root, query, cb) -> { if (from == null && to == null) return null; if (from == null) return cb.lessThanOrEqualTo(root.get("createdAt"), to); if (to == null) return cb.greaterThanOrEqualTo(root.get("createdAt"), from); return cb.between(root.get("createdAt"), from, to); }; } public static Specification<Order> totalAbove(BigDecimal minTotal) { return (root, query, cb) -> minTotal == null ? null : cb.greaterThanOrEqualTo(root.get("total"), minTotal); } }

Composing Specifications

@Service @RequiredArgsConstructor public class OrderSearchService { private final OrderRepository orderRepository; public Page<OrderDto> search(OrderSearchRequest req, Pageable pageable) { Specification<Order> spec = Specification .where(hasStatus(req.getStatus())) .and(forCustomer(req.getCustomerId())) .and(inRegion(req.getRegion())) .and(createdBetween(req.getFrom(), req.getTo())) .and(totalAbove(req.getMinTotal())); // Null specs are ignored — only active filters apply return orderRepository.findAll(spec, pageable) .map(OrderMapper::toDto); } }

This handles all 32 combinations (and more) with 5 lines of composition — no method explosion.


Type-Safe Specifications with Metamodel

// Generate metamodel with Maven: // <dependency> hibernate-jpamodelgen </dependency> // Order_ is auto-generated from @Entity Order public class OrderSpecifications { public static Specification<Order> hasStatus(OrderStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get(Order_.status), status); // Compile-time safe! } // JOIN to related entity public static Specification<Order> forCustomerEmail(String email) { return (root, query, cb) -> { if (email == null) return null; Join<Order, Customer> customer = root.join(Order_.customer); return cb.equal(customer.get(Customer_.email), email); }; } // LIKE search public static Specification<Order> noteContains(String keyword) { return (root, query, cb) -> keyword == null ? null : cb.like(cb.lower(root.get(Order_.notes)), "%" + keyword.toLowerCase() + "%"); } }

Order_.status is a compile-time constant — renaming the field in Order immediately breaks the spec, rather than failing at runtime.


@RestController @RequestMapping("/api/v1/orders") @RequiredArgsConstructor public class OrderController { private final OrderSearchService searchService; @GetMapping public Page<OrderDto> search( @RequestParam(required = false) OrderStatus status, @RequestParam(required = false) Long customerId, @RequestParam(required = false) String region, @RequestParam(required = false) @DateTimeFormat(iso=DATE_TIME) LocalDateTime from, @RequestParam(required = false) @DateTimeFormat(iso=DATE_TIME) LocalDateTime to, @RequestParam(required = false) BigDecimal minTotal, Pageable pageable) { return searchService.search( new OrderSearchRequest(status, customerId, region, from, to, minTotal), pageable ); } }

All parameters optional. Any combination works. One method handles all search cases.


Avoiding the N+1 in Specifications

// Specifications can cause N+1 for JOIN FETCHes public static Specification<Order> withItems() { return (root, query, cb) -> { if (query.getResultType() == Long.class) return null; // Skip for count query root.fetch(Order_.items, JoinType.LEFT); // Fetch join query.distinct(true); return null; // No predicate, just fetch }; }

The query.getResultType() == Long.class check prevents the fetch join from running on the count query (which would cause issues with Hibernate).


Common Mistakes to Avoid

  • Not handling null in specifications — returning cb.isNull(root.get(...)) instead of null from the spec means "where field IS NULL", not "ignore this filter"
  • JOIN in spec without fetchroot.join() creates an INNER JOIN predicate; root.fetch() is a JOIN FETCH for eager loading — they're different
  • Applying fetch join to count query — the count query for Page<T> runs the same spec; a fetch join in a count query throws an exception
  • Cartesian product with multiple fetch joins — same issue as regular JPA: two fetch joins on collections cause a Cartesian product

Summary

Spring Data JPA Specifications replace repository method explosion with composable query predicates. Write one Specification<T> per filter, compose them with .and() / .or(), and pass them to findAll(spec, pageable). Null specifications are ignored — partial filter combinations work automatically. Use the JPA metamodel for compile-time type safety. The result is a clean, maintainable search API that handles any filter combination with one service method.


Detect Query Anti-Patterns in Your Repositories

JOptimize flags repository method proliferation with too many findByXAndYAndZ() methods, missing pagination on search endpoints, and missing indexes on filtered columns.

Simplify your repository layer with composable queries — free scan.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.