Spring BootFiltering APIs
Spring Boot

Filtering APIs

Filtering lets clients narrow a collection to only the records they need. Spring Boot supports filtering through query parameters, JPA Specifications, Querydsl predicates, and dedicated filter objects. This entry covers simple parameter filtering, dynamic multi-criteria filtering with Specifications, Querydsl integration, and a reusable filter DTO pattern.

Simple Query Parameter Filtering

For a small fixed set of filter criteria, bind each filter as a @RequestParam and build the query with a derived repository method or JPQL. Mark parameters as required = false so clients can omit filters they do not need.
Java
// ── Controller ────────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
        @RequestParam(required = false) UserStatus status,
        @RequestParam(required = false) String     department,
        @RequestParam(required = false)
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate joinedAfter,
        @PageableDefault(size = 20) Pageable pageable) {

    return ResponseEntity.ok(
        PageResponse.from(
            userService.findAll(status, department, joinedAfter, pageable)));
}

// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("""
        SELECT u FROM User u
        WHERE (:status     IS NULL OR u.status            = :status)
          AND (:department IS NULL OR u.department.name   = :department)
          AND (:joinedAfter IS NULL OR u.createdAt        >= :joinedAfter)
        """)
    Page<User> findWithFilters(
        @Param("status")      UserStatus status,
        @Param("department")  String     department,
        @Param("joinedAfter") LocalDate  joinedAfter,
        Pageable pageable);
}

// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public Page<UserResponse> findAll(UserStatus status, String department,
                                      LocalDate joinedAfter, Pageable pageable) {
        return userRepo
            .findWithFilters(status, department, joinedAfter, pageable)
            .map(UserResponse::from);
    }
}

Dynamic Filtering with Specifications

JPA Specifications (the Criteria API wrapped in a functional interface) compose WHERE clauses dynamically at runtime. Each criterion is a small Specification lambda; Specification.where().and().and() chains them. Only non-null criteria are applied, so the query adapts to whichever filters the client supplies.
Java
// ── Specification factory ─────────────────────────────────────────────
public class UserSpecifications {

    public static Specification<User> hasStatus(UserStatus status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<User> inDepartment(String dept) {
        return (root, query, cb) ->
            dept == null ? null
                         : cb.equal(root.get("department").get("name"), dept);
    }

    public static Specification<User> nameLike(String name) {
        return (root, query, cb) ->
            name == null ? null
                         : cb.like(cb.lower(root.get("name")),
                                   "%" + name.toLowerCase() + "%");
    }

    public static Specification<User> joinedAfter(LocalDate date) {
        return (root, query, cb) ->
            date == null ? null
                         : cb.greaterThanOrEqualTo(
                               root.get("createdAt"), date.atStartOfDay());
    }

    public static Specification<User> ageBetween(Integer min, Integer max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min == null) return cb.lessThanOrEqualTo(root.get("age"), max);
            if (max == null) return cb.greaterThanOrEqualTo(root.get("age"), min);
            return cb.between(root.get("age"), min, max);
        };
    }
}

// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository
        extends JpaRepository<User, Long>,
                JpaSpecificationExecutor<User> {}

// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public Page<UserResponse> search(UserStatus status, String dept,
                                     String name, LocalDate joinedAfter,
                                     Integer minAge, Integer maxAge,
                                     Pageable pageable) {
        Specification<User> spec = Specification
            .where(hasStatus(status))
            .and(inDepartment(dept))
            .and(nameLike(name))
            .and(joinedAfter(joinedAfter))
            .and(ageBetween(minAge, maxAge));

        return userRepo.findAll(spec, pageable).map(UserResponse::from);
    }
}

Filter DTO Pattern

As the number of filter criteria grows, individual @RequestParam parameters become unwieldy. Bind all filters into a single filter DTO using @ModelAttribute. Spring populates it from query parameters by field name, Bean Validation annotations apply normally, and the controller signature stays clean regardless of how many criteria are added later.
Java
// ── Filter DTO ────────────────────────────────────────────────────────
@Data
public class UserFilter {

    private UserStatus status;

    private String department;

    @Size(max = 100)
    private String name;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate joinedAfter;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate joinedBefore;

    @Min(0) @Max(120)
    private Integer minAge;

    @Min(0) @Max(120)
    private Integer maxAge;

    private String role;
}

// ── Controller ────────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> search(
        @ModelAttribute @Valid UserFilter filter,
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC) Pageable pageable) {
    return ResponseEntity.ok(
        PageResponse.from(userService.search(filter, pageable)));
}

// ── Specification builder from filter DTO ─────────────────────────────
@Component
public class UserSpecificationBuilder {

    public Specification<User> build(UserFilter f) {
        return Specification
            .where(hasStatus(f.getStatus()))
            .and(inDepartment(f.getDepartment()))
            .and(nameLike(f.getName()))
            .and(joinedAfter(f.getJoinedAfter()))
            .and(joinedBefore(f.getJoinedBefore()))
            .and(ageBetween(f.getMinAge(), f.getMaxAge()))
            .and(hasRole(f.getRole()));
    }

    private Specification<User> joinedBefore(LocalDate date) {
        return (root, query, cb) ->
            date == null ? null
                         : cb.lessThanOrEqualTo(
                               root.get("createdAt"), date.atTime(23, 59, 59));
    }

    private Specification<User> hasRole(String role) {
        return (root, query, cb) ->
            role == null ? null : cb.equal(root.get("role"), role);
    }
}

// ── Example: GET /users?status=ACTIVE&department=Engineering&minAge=25&sort=name,asc

Querydsl Integration

Querydsl generates a strongly-typed Q-class for every entity at compile time. Predicates are built with type-safe method calls instead of string-based Criteria API calls. Spring Data's QuerydslPredicateExecutor integrates it directly into the repository, and @QuerydslPredicate in the controller binds query parameters to predicates automatically.
XML
<!-- pom.xml -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository
        extends JpaRepository<User, Long>,
                QuerydslPredicateExecutor<User> {}

// ── Controller — automatic binding via @QuerydslPredicate ─────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
        @QuerydslPredicate(root = User.class) Predicate predicate,
        @PageableDefault(size = 20) Pageable pageable) {
    // Spring maps query params to QUser fields automatically
    // GET /users?status=ACTIVE&department.name=Engineering
    return ResponseEntity.ok(
        PageResponse.from(
            userRepo.findAll(predicate, pageable).map(UserResponse::from)));
}

// ── Manual Querydsl predicate construction ────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public Page<UserResponse> search(UserFilter filter, Pageable pageable) {
        QUser user = QUser.user;
        BooleanBuilder predicate = new BooleanBuilder();

        if (filter.getStatus() != null)
            predicate.and(user.status.eq(filter.getStatus()));

        if (filter.getName() != null)
            predicate.and(user.name.containsIgnoreCase(filter.getName()));

        if (filter.getDepartment() != null)
            predicate.and(user.department.name.eq(filter.getDepartment()));

        if (filter.getMinAge() != null)
            predicate.and(user.age.goe(filter.getMinAge()));

        if (filter.getMaxAge() != null)
            predicate.and(user.age.loe(filter.getMaxAge()));

        return userRepo.findAll(predicate, pageable).map(UserResponse::from);
    }
}

Full-Text Search Filter

For keyword search across multiple fields, a LIKE query on each column is simple but slow at scale. Use a JPQL CONCAT approach for small datasets, a database full-text index for medium datasets, or Elasticsearch for large-scale search requirements.
Java
// ── Option 1: Multi-column LIKE (small datasets) ─────────────────────
@Query("""
    SELECT u FROM User u
    WHERE LOWER(u.name)  LIKE LOWER(CONCAT('%', :q, '%'))
       OR LOWER(u.email) LIKE LOWER(CONCAT('%', :q, '%'))
       OR LOWER(u.department.name) LIKE LOWER(CONCAT('%', :q, '%'))
    """)
Page<User> fullTextSearch(@Param("q") String query, Pageable pageable);

// ── Option 2: PostgreSQL full-text search (native query) ──────────────
@Query(value = """
    SELECT * FROM users
    WHERE to_tsvector('english', name || ' ' || email || ' ' || bio)
          @@ plainto_tsquery('english', :query)
    ORDER BY ts_rank(
        to_tsvector('english', name || ' ' || email || ' ' || bio),
        plainto_tsquery('english', :query)
    ) DESC
    """,
    countQuery = """
    SELECT COUNT(*) FROM users
    WHERE to_tsvector('english', name || ' ' || email || ' ' || bio)
          @@ plainto_tsquery('english', :query)
    """,
    nativeQuery = true)
Page<User> fullTextSearchPg(@Param("query") String query, Pageable pageable);

// ── Controller ────────────────────────────────────────────────────────
@GetMapping("/search")
public ResponseEntity<Page<UserResponse>> search(
        @RequestParam @NotBlank @Size(min = 2, max = 100) String q,
        @PageableDefault(size = 20) Pageable pageable) {
    return ResponseEntity.ok(
        PageResponse.from(userService.search(q, pageable)));
}

// ── Example: GET /users/search?q=alice&page=0&size=10