DEV Community

Ed Legaspi
Ed Legaspi

Posted on Originally published at czetsuyatech.com

Dynamic Queries in Spring Data JPA Without Repository Method Explosion

NERV Persistence article cover illustrating dynamic queries in Spring Data JPA using composable Specifications, optional search filters, pagination, and SQL query generation

Spring Data JPA makes repository development incredibly convenient.

You define an entity, extend JpaRepository, and start writing methods such as:

List<Customer> findByStatus(CustomerStatus status);

List<Customer> findByCountry(String country);

List<Customer> findByStatusAndCountry(
    CustomerStatus status,
    String country
);
Enter fullscreen mode Exit fullscreen mode

Nothing wrong with that.

Until your application needs a search endpoint with eight optional filters.

Suddenly, the repository starts accumulating methods that differ only in which filters are applied.

The problem isn't Spring Data JPA.

The problem is using fixed repository methods to represent a dynamic query.

Let's build a cleaner approach using Spring Data JPA Specifications.

1. Why Derived Query Methods Stop Scaling

Imagine a customer search API with the following filters:

  • Status
  • Country
  • Customer type
  • Registration date
  • Email
  • Account manager

All filters are optional.

A user might search by status alone, country and type together, or status, country, registration date, and account manager.

With six independent optional filters, there are potentially:

2⁶ = 64 combinations.

With eight filters, that becomes 256.

Of course, you don't necessarily need to implement every combination.

But the design problem is obvious.

Repository methods describe specific queries, while the API needs composable conditions.

We need to model the individual predicates instead of their combinations.

2. The Tempting Solution: Nullable JPQL Parameters

A common approach is to create one query with optional parameters.

@Query("""
    SELECT c
    FROM Customer c
    WHERE (:status IS NULL OR c.status = :status)
      AND (:country IS NULL OR c.country = :country)
      AND (:type IS NULL OR c.type = :type)
    """)
List<Customer> search(
    @Param("status") CustomerStatus status,
    @Param("country") String country,
    @Param("type") CustomerType type
);
Enter fullscreen mode Exit fullscreen mode

This works for many applications.

However, it becomes harder to maintain as more filters are introduced.

You also need to consider how the database optimizer handles the optional OR predicates.

Depending on the database, parameter values, indexes, and execution plan, the query may perform differently from a query containing only the conditions actually needed.

This doesn't mean nullable JPQL parameters are always wrong.

It means they're not always the best abstraction for an evolving search API.

3. Enable Specification Execution

Spring Data JPA provides JpaSpecificationExecutor, which allows repositories to execute composable Specification<T> predicates.

First, consider a simple entity.

@Entity
@Table(name = "customer")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private CustomerStatus status;

    private String country;

    @Enumerated(EnumType.STRING)
    private CustomerType type;

    private Instant registeredAt;

    // Getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Now extend JpaSpecificationExecutor.

public interface CustomerRepository
        extends JpaRepository<Customer, Long>,
                JpaSpecificationExecutor<Customer> {
}
Enter fullscreen mode Exit fullscreen mode

That's it.

We can now execute Specifications without creating a separate repository method for each filter combination.

4. Create Reusable Specifications

Let's define one Specification per condition.

public final class CustomerSpecifications {

    private CustomerSpecifications() {
    }

    public static Specification<Customer> hasStatus(
            CustomerStatus status) {

        return (root, query, cb) ->
            cb.equal(root.get("status"), status);
    }

    public static Specification<Customer> hasCountry(
            String country) {

        return (root, query, cb) ->
            cb.equal(root.get("country"), country);
    }

    public static Specification<Customer> hasType(
            CustomerType type) {

        return (root, query, cb) ->
            cb.equal(root.get("type"), type);
    }
}
Enter fullscreen mode Exit fullscreen mode

Each method describes one predicate.

It doesn't know which other filters will be applied.

That's the key advantage.

We can combine these conditions without creating additional repository methods.

Specification<Customer> specification =
    Specification.allOf(
        CustomerSpecifications.hasStatus(
            CustomerStatus.ACTIVE
        ),
        CustomerSpecifications.hasCountry("PH")
    );

List<Customer> customers =
    customerRepository.findAll(specification);
Enter fullscreen mode Exit fullscreen mode

Conceptually, the resulting SQL resembles:

SELECT *
FROM customer
WHERE status = 'ACTIVE'
  AND country = 'PH';
Enter fullscreen mode Exit fullscreen mode

The exact SQL depends on the JPA provider and dialect.

5. Build a Dynamic Search Criteria Object

Real search endpoints receive optional values.

Let's represent them explicitly.

public record CustomerSearchCriteria(
    CustomerStatus status,
    String country,
    CustomerType type,
    LocalDate registeredFrom,
    LocalDate registeredTo
) {
}
Enter fullscreen mode Exit fullscreen mode

Now create a method that builds a Specification using only the supplied filters.

Add this method to CustomerSpecifications:

public static Specification<Customer> from(
        CustomerSearchCriteria criteria) {

    List<Specification<Customer>> filters =
        new ArrayList<>();

    if (criteria.status() != null) {
        filters.add(hasStatus(criteria.status()));
    }

    if (criteria.country() != null
            && !criteria.country().isBlank()) {

        filters.add(hasCountry(criteria.country()));
    }

    if (criteria.type() != null) {
        filters.add(hasType(criteria.type()));
    }

    if (criteria.registeredFrom() != null) {
        filters.add(
            registeredOnOrAfter(criteria.registeredFrom())
        );
    }

    if (criteria.registeredTo() != null) {
        filters.add(
            registeredBefore(criteria.registeredTo().plusDays(1))
        );
    }

    return Specification.allOf(filters);
}
Enter fullscreen mode Exit fullscreen mode

Notice what we're not doing.

We're not writing separate methods for:

findByStatusAndCountry()
findByStatusAndType()
findByCountryAndType()
findByStatusAndCountryAndType()
Enter fullscreen mode Exit fullscreen mode

Instead, we're assembling predicates dynamically.

Adding another filter becomes a localized change.

A note about empty filters

Specification.allOf(...) supports an empty collection.

An empty set of filters results in an unrestricted query.

That might be appropriate for an administrative search endpoint, but not every API should permit unrestricted searches.

You may need additional authorization, tenant, or visibility restrictions that apply regardless of user-supplied filters.

6. Handle Date Ranges Correctly

Date filtering is one of those details that looks simple until you encounter timezone and timestamp precision problems.

Suppose the entity stores registration timestamps as Instant.

We want users to search between two calendar dates.

One tempting approach is to create an end-of-day timestamp such as:

2026-10-05T23:59:59.999999999Z
Enter fullscreen mode Exit fullscreen mode

But timestamp precision varies between databases and column types.

A better approach is to use a half-open interval.

registeredAt >= start
registeredAt <  endExclusive
Enter fullscreen mode Exit fullscreen mode

For example, October 1 through October 5 becomes:

registeredAt >= 2026-10-01T00:00:00Z
registeredAt <  2026-10-06T00:00:00Z
Enter fullscreen mode Exit fullscreen mode

Implement the predicates:

public static Specification<Customer> registeredOnOrAfter(
        LocalDate date) {

    Instant start = date
        .atStartOfDay(ZoneOffset.UTC)
        .toInstant();

    return (root, query, cb) ->
        cb.greaterThanOrEqualTo(
            root.get("registeredAt"),
            start
        );
}

public static Specification<Customer> registeredBefore(
        LocalDate exclusiveDate) {

    Instant end = exclusiveDate
        .atStartOfDay(ZoneOffset.UTC)
        .toInstant();

    return (root, query, cb) ->
        cb.lessThan(
            root.get("registeredAt"),
            end
        );
}
Enter fullscreen mode Exit fullscreen mode

These examples use UTC calendar boundaries.

If your business defines dates in a specific local timezone, convert those dates using the appropriate ZoneId.

Don't assume UTC calendar days and business-local calendar days are interchangeable.

7. Add Pagination and Sorting

Dynamic filtering is useful, but a production search API also needs pagination.

Fortunately, JpaSpecificationExecutor already supports Pageable.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CustomerSearchService {

    private final CustomerRepository customerRepository;

    public Page<Customer> search(
            CustomerSearchCriteria criteria,
            Pageable pageable) {

        Specification<Customer> specification =
            CustomerSpecifications.from(criteria);

        return customerRepository.findAll(
            specification,
            pageable
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller can expose the search endpoint:

@RestController
@RequestMapping("/api/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerSearchService searchService;

    @GetMapping
    public Page<CustomerResponse> search(
            @ModelAttribute CustomerSearchCriteria criteria,
            Pageable pageable) {

        return searchService
            .search(criteria, pageable)
            .map(CustomerResponse::from);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, CustomerResponse.from(...) represents an application-defined mapping method, not a Spring Data JPA API.

A request might look like:

GET /api/customers?status=ACTIVE&country=PH&page=0&size=20&sort=registeredAt,desc
Enter fullscreen mode Exit fullscreen mode

The repository doesn't need to know which filter combination was selected.

It receives a Specification and a Pageable.

For production APIs, remember to enforce maximum page sizes and restrict which properties can be used for sorting.

8. What About Joins?

Specifications can also filter through entity relationships.

Suppose a customer has many orders.

We want customers with completed orders.

A straightforward implementation might be:

public static Specification<Customer> hasCompletedOrder() {

    return (root, query, cb) -> {
        Join<Customer, Order> orders =
            root.join("orders");

        return cb.equal(
            orders.get("status"),
            OrderStatus.COMPLETED
        );
    };
}
Enter fullscreen mode Exit fullscreen mode

But there's an important catch.

If a customer has multiple completed orders, the SQL join can produce multiple matching rows.

This can complicate pagination and counts.

For an existence condition, an EXISTS subquery is often a better fit.

public static Specification<Customer> hasCompletedOrder() {

    return (root, query, cb) -> {

        Subquery<Long> subquery =
            query.subquery(Long.class);

        Root<Order> order = subquery.from(Order.class);

        subquery.select(cb.literal(1L));

        subquery.where(
            cb.equal(order.get("customer"), root),
            cb.equal(
                order.get("status"),
                OrderStatus.COMPLETED
            )
        );

        return cb.exists(subquery);
    };
}
Enter fullscreen mode Exit fullscreen mode

This example assumes Order has a customer relationship.

The important lesson is that query composition does not remove the need to understand relational behavior.

A Specification is still a database query.

9. Watch Out for Count Queries

When you call:

Page<Customer> page =
    customerRepository.findAll(specification, pageable);
Enter fullscreen mode Exit fullscreen mode

Spring Data JPA generally needs to determine the total number of matching records to populate the page metadata.

For simple queries, that's usually fine.

For queries involving expensive joins or large datasets, the count query can become a significant part of the request cost.

Some useful options include:

  • Use a Slice-oriented retrieval strategy when total counts aren't required.
  • Keep expensive fetch behavior out of count queries.
  • Consider separate content and count Specifications where supported.
  • Inspect generated SQL and execution plans.

Don't assume that a dynamic query is efficient just because the Java code is concise.

10. Where NERV Persistence Fits

So far, everything we've implemented uses native Spring Data JPA functionality.

That's intentional.

Spring Data JPA already gives us the core query composition primitives.

But when multiple services implement dynamic search, developers may still repeatedly write infrastructure for:

  • Optional filter handling
  • Operator translation
  • Search request validation
  • Pagination conventions
  • Sorting conventions
  • Common query construction

This is the kind of repetition that motivates reusable persistence infrastructure.

NERV Persistence is built around the idea of standardizing common persistence mechanics while allowing each application to retain its own domain-specific behavior.

The intended architectural separation is:

REST API
    |
    v
Search Criteria
    |
    v
Domain Specifications
    |
    v
Reusable Persistence Infrastructure
    |
    v
Spring Data JPA
    |
    v
Hibernate / Database
Enter fullscreen mode Exit fullscreen mode

This diagram is conceptual; it doesn't imply that every layer corresponds to a specific NERV Persistence class.

The goal isn't to hide Spring Data JPA.

It's to reduce repeated infrastructure work without losing the flexibility of the underlying framework.

You can explore the project here:

NERV Persistence on GitHub

11. When Not to Use Specifications

Specifications are powerful, but they're not always the right abstraction.

For a fixed lookup, a derived method is often clearer:

Optional<Customer> findByExternalId(String externalId);
Enter fullscreen mode Exit fullscreen mode

For complex reporting queries involving aggregations, window functions, or database-specific features, explicit SQL may be easier to maintain and optimize.

For high-volume read models, specialized projections or query implementations may be more appropriate.

The objective isn't to eliminate repository methods.

It's to stop representing dynamic search requirements as a growing collection of fixed repository methods.

Final Thoughts

Derived repository methods are one of Spring Data JPA's best features.

But they work best when the query is fixed.

Once search filters become optional and combinatorial, query composition becomes a better abstraction.

Specifications allow us to:

  • Define predicates independently.
  • Combine only the filters supplied by a request.
  • Integrate naturally with pagination and sorting.
  • Keep repository interfaces small.
  • Evolve search behavior without multiplying repository methods.

And when these patterns appear across multiple applications, reusable persistence infrastructure can help standardize the mechanics without hiding domain intent.

The goal isn't to write the least amount of code.

It's to make the next search requirement easier to implement without making the existing persistence layer harder to understand.


This is Article #2 in the NERV Persistence series, exploring practical persistence architecture for Java and Spring Boot applications.

Explore NERV Persistence:

https://github.com/czetsuyatech/nerv-persistence

Read the canonical article on CzetsuyaTech:

https://www.czetsuyatech.com/2026/10/dynamic-queries-spring-data-jpa.html

If you work with dynamic search APIs in Spring Data JPA, what's your preferred approach: Specifications, QueryDSL, Criteria API, or explicit JPQL?

Top comments (0)