Specifications & Dynamic Queries
The Specification API for building dynamic, composable queries, and when it beats derived query methods.
The problem: an optional, multi-field search
Derived query methods work well for a fixed, known set of conditions. They fall apart the moment a search endpoint needs to filter on any combination of several optional criteria — a naive approach explodes into one derived method per combination, or a pile of conditionally-built JPQL strings:
// This does not scale -- every new optional filter doubles the combinations needed
List<Book> findByAuthor(String author);
List<Book> findByAuthorAndGenre(String author, String genre);
List<Book> findByGenreAndPublishedAfter(String genre, LocalDate date);
// ...and so on, for every combination a caller might supply
The Specification API
A Specification<T> is a composable, type-safe predicate builder — instead of one derived method per combination, you build up exactly the query needed for the filters that were actually supplied at runtime:
public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> {
}
public class BookSpecifications {
public static Specification<Book> hasAuthor(String author) {
return (root, query, cb) -> cb.equal(root.get("author"), author);
}
public static Specification<Book> hasGenre(String genre) {
return (root, query, cb) -> cb.equal(root.get("genre"), genre);
}
public static Specification<Book> publishedAfter(LocalDate date) {
return (root, query, cb) -> cb.greaterThan(root.get("publishedDate"), date);
}
}
Composing specifications conditionally
The real payoff is combining these fluently, including only the conditions a given search request actually provided:
@Service
public class BookSearchService {
private final BookRepository bookRepository;
public BookSearchService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> search(String author, String genre, LocalDate publishedAfter) {
Specification<Book> spec = Specification.where(null);
if (author != null) {
spec = spec.and(BookSpecifications.hasAuthor(author));
}
if (genre != null) {
spec = spec.and(BookSpecifications.hasGenre(genre));
}
if (publishedAfter != null) {
spec = spec.and(BookSpecifications.publishedAfter(publishedAfter));
}
return bookRepository.findAll(spec);
}
}
A caller supplying only author gets a query filtered on just that field; a caller supplying all three gets all three AND-ed together — one method, any combination, with each individual Specification staying small, named, and independently testable.
Combined with Pageable, the same specification works for paginated search too, since JpaSpecificationExecutor overloads findAll to accept one:
Page<Book> results = bookRepository.findAll(spec, PageRequest.of(0, 20));
Specifications vs derived query methods
| Derived query methods | Specification |
|
|---|---|---|
| Best for | A fixed, known, small set of query shapes | An arbitrary, optional combination of filters decided at runtime |
| Readability | Very readable for 1-3 conditions | Method names would become unreadable at the same complexity |
| Type safety | Compile-time checked against entity fields (via the method name) | Compile-time checked against fields (via strings, or the JPA metamodel for full type safety) |
| Reusability | Each method is its own fixed query | Small Specification building blocks compose into many different queries |
| Extra interface needed | None | JpaSpecificationExecutor<T> |
Common mistakes
- Reaching for
Specificationby default for every repository, even ones with a small, fixed set of query shapes — plain derived methods or@Queryare simpler and just as correct when the filters aren't genuinely dynamic. - Building a single, giant
Specificationwith deeply nested conditional logic instead of composing several small, individually named specifications (hasAuthor,hasGenre) — defeats the readability and reuse the API is meant to provide. - Using raw string field names (
root.get("author")) throughout instead of the JPA static metamodel, giving up compile-time safety against a renamed or removed field — acceptable for a small app, worth reconsidering as the codebase grows. - Forgetting to extend
JpaSpecificationExecutor<T>alongsideJpaRepository<T, ID>— without it, the repository has nofindAll(Specification<T>)overload to call at all.