Specifications & Dynamic Queries

The Specification API for building dynamic, composable queries, and when it beats derived query methods.

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:

Java
// 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:

Java
public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> {
}
Java
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:

Java
@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:

Java
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 Specification by default for every repository, even ones with a small, fixed set of query shapes — plain derived methods or @Query are simpler and just as correct when the filters aren't genuinely dynamic.
  • Building a single, giant Specification with 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> alongside JpaRepository<T, ID> — without it, the repository has no findAll(Specification<T>) overload to call at all.