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,ascQuerydsl 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