Skip to content

Spring Boot JPA (Part 10) | Specification API and Dynamic Filtering

The Modular Camera Filter Analogy

Imagine a professional landscape photographer packing for an expedition. The photographer carries three optical lens filters in their bag:

  1. Polarizer Filter: Cuts water surface glare.
  2. Neutral Density Filter: Reduces overall light for long exposure waterfall shots.
  3. Warm Color Filter: Enhances sunset golden tones.

The photographer does not buy eight different specialized lenses with fixed glass combinations. They carry one versatile camera lens, and depending on the weather conditions, they snap on whichever filters are needed. On a clear afternoon, they screw on the polarizer alone. At sunset over the ocean, they screw on both the polarizer and the warm filter. The filters are modular, reusable, and composable.

In Spring Data JPA, Specifications are those modular optical filters. Writing dynamic Criteria queries from scratch in DAO classes creates massive code duplication and clunky boilerplate. The Specification API encapsulates individual query criteria into reusable, composable filter predicates. You can combine specifications dynamically using simple logical operators like .and(), .or(), and .not(), passing them directly to standard repository methods.

This lecture covers the flaws of raw Criteria API, the Specification<T> functional interface, JpaSpecificationExecutor, and building a production grade dynamic search filter for REST APIs.


Why Criteria API Needed an Abstraction

In the previous lecture, we built dynamic searches using raw Criteria API. While functional, it has two major drawbacks:

  1. Massive Boilerplate: Every search method must manage EntityManager, CriteriaBuilder, CriteriaQuery, Root, and array conversions.
  2. Zero Reusability: A predicate checking status = 'ACTIVE' written inside ProductSearchDao cannot easily be reused in OrderSearchDao or combined with another query without copy pasting code.

Spring Data JPA solved this by introducing the Specification API (org.springframework.data.jpa.domain.Specification), inspired by Eric Evans' Domain Driven Design Specification pattern.


The Specification<T> Interface

Specification<T> is a functional interface:

java
@FunctionalInterface
public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);

    default Specification<T> and(Specification<T> other) { /* ... */ }
    default Specification<T> or(Specification<T> other) { /* ... */ }
    static <T> Specification<T> not(Specification<T> spec) { /* ... */ }
}

Notice the power: because it has default methods for and(), or(), and not(), specifications can be chained together like Lego blocks:

java
Specification<Order> query = Specification
    .where(hasCustomer("CUST-101"))
    .and(hasStatus("CONFIRMED"))
    .and(amountGreaterThan(250.00));

Enabling Specifications on Repositories

To execute specifications, your repository interface must extend JpaSpecificationExecutor<T>:

java
package com.example.orderservice.repository;

import com.example.orderservice.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
    // Inherits findAll(Specification), count(Specification), findOne(Specification)
}

By extending JpaSpecificationExecutor<T>, your repository automatically gains methods:

  • List<T> findAll(Specification<T> spec)
  • Page<T> findAll(Specification<T> spec, Pageable pageable)
  • List<T> findAll(Specification<T> spec, Sort sort)
  • long count(Specification<T> spec)
  • Optional<T> findOne(Specification<T> spec)

Building Reusable Specifications

Define your specifications as static methods inside a dedicated utility class:

java
package com.example.orderservice.specification;

import com.example.orderservice.entity.Order;
import org.springframework.data.jpa.domain.Specification;

public class OrderSpecifications {

    public static Specification<Order> hasStatus(String status) {
        return (root, query, cb) -> {
            if (status == null || status.isBlank()) {
                return cb.conjunction(); // returns true (no-op filter)
            }
            return cb.equal(root.get("status"), status);
        };
    }

    public static Specification<Order> hasCustomerId(String customerId) {
        return (root, query, cb) -> {
            if (customerId == null || customerId.isBlank()) {
                return cb.conjunction();
            }
            return cb.equal(root.get("customerId"), customerId);
        };
    }

    public static Specification<Order> amountGreaterThan(Double minAmount) {
        return (root, query, cb) -> {
            if (minAmount == null) {
                return cb.conjunction();
            }
            return cb.greaterThanOrEqualTo(root.get("amount"), minAmount);
        };
    }

    public static Specification<Order> titleContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) {
                return cb.conjunction();
            }
            return cb.like(cb.lower(root.get("title")), "%" + keyword.toLowerCase() + "%");
        };
    }
}

Notice cb.conjunction(): If a parameter is null, returning cb.conjunction() is equivalent to WHERE 1=1, producing a clean no op filter that does not affect the query.


Building Dynamic REST Search Endpoints

Now, consider a REST controller endpoint receiving optional filter parameters:

java
package com.example.orderservice.controller;

import com.example.orderservice.entity.Order;
import com.example.orderservice.repository.OrderRepository;
import com.example.orderservice.specification.OrderSpecifications;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderSearchController {

    @Autowired
    private OrderRepository orderRepository;

    @GetMapping("/api/orders/search")
    public Page<Order> searchOrders(
            @RequestParam(required = false) String status,
            @RequestParam(required = false) String customerId,
            @RequestParam(required = false) Double minAmount,
            @RequestParam(required = false) String keyword,
            Pageable pageable) {

        // Dynamically compose specifications
        Specification<Order> spec = Specification
            .where(OrderSpecifications.hasStatus(status))
            .and(OrderSpecifications.hasCustomerId(customerId))
            .and(OrderSpecifications.amountGreaterThan(minAmount))
            .and(OrderSpecifications.titleContains(keyword));

        // Pass composite specification and pagination to repository
        return orderRepository.findAll(spec, pageable);
    }
}

Look at the simplicity:

  • Zero SQL string concatenation.
  • Zero manual EntityManager lookups.
  • Full support for pagination and sorting via Pageable.
  • Reusable, testable individual filter functions.

Interview Questions & Pitfalls

Q1: What interface must a Spring Data JPA repository extend to support the Specification API?

The repository must extend JpaSpecificationExecutor<T>. This interface provides overloaded methods including findAll(Specification<T>) and findAll(Specification<T>, Pageable).

Q2: What is the purpose of returning criteriaBuilder.conjunction() inside a Specification?

criteriaBuilder.conjunction() creates a predicate representing a boolean true condition (equivalent to 1=1 in SQL). When an optional filter parameter is null or empty, returning a conjunction ensures that the specification acts as a clean no op without altering the query results.

Q3: How do you combine multiple specifications dynamically?

Use the default methods on Specification: specA.and(specB), specA.or(specB), or Specification.not(specA). You can start a chain using Specification.where(initialSpec).

Q4: Can Specifications perform table joins?

Yes. Inside the toPredicate method, you can invoke root.join("associationName") on the Root parameter:

java
public static Specification<Order> customerNameContains(String name) {
    return (root, query, cb) -> {
        Join<Order, Customer> customer = root.join("customer", JoinType.INNER);
        return cb.like(cb.lower(customer.get("name")), "%" + name.toLowerCase() + "%");
    };
}

Q5: What is the primary advantage of the Specification pattern over writing multiple derived query methods?

Derived query methods explode exponentially with optional search filters. For four optional filter fields, you would need sixteen distinct query methods to cover all permutation combinations (findByA, findByAAndB, findByBAndC, etc.). The Specification API builds one query dynamically from composable predicates, handling all permutations with zero code duplication.