Spring Boot Sorting & Filtering with JPA Specifications (Deep Dive)
Most real-world applications need powerful search APIs: filter by multiple fields, do range queries, combine conditions with AND/OR, and sort results — all without writing 20 different repository methods.
This is exactly where JPA Specifications shine. They let you build dynamic filtering and sorting in a clean, type-safe way.
Spring Boot 3 + Spring Data JPA + JpaSpecificationExecutor, step by step.
We’ll go beyond simple examples and cover joins, ranges, enums, pagination, and best practices.
1. What Are JPA Specifications?
A Specification<T> in Spring Data JPA is a way to define dynamic where conditions
using the JPA Criteria API, but in a more readable, composable style.
Think of a Specification as:
- A reusable filter condition like “price >= 5000”, “status = ACTIVE”, “name contains 'phone'”
- Can be combined using
and()/or() - Works nicely with pagination & sorting
2. Project Setup (Spring Boot 3 + JPA)
2.1 Dependencies (Maven)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
For a real application, you can use MySQL/Oracle instead of H2, similar to other posts on this blog.
3. Example Domain Model — Product & Category
We’ll build a catalogue API for filtering products by:
- Name contains text
- Category name
- Price range
- Active flag
- Created date range
- Rating range
3.1 Category Entity
package com.example.specdemo.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "categories")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
}
3.2 Product Entity
package com.example.specdemo.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
private Double rating;
private boolean active;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
}
4. Repository with JpaSpecificationExecutor
4.1 ProductRepository
package com.example.specdemo.repository;
import com.example.specdemo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
That’s it. By extending JpaSpecificationExecutor, we get methods like:
findAll(Specification spec)findAll(Specification spec, Pageable pageable)count(Specification spec)
5. First Specification — Filter by Active Flag
5.1 Simple Specification using Lambda
import org.springframework.data.jpa.domain.Specification;
import com.example.specdemo.entity.Product;
public class ProductSpecifications {
public static Specification<Product> hasActive(Boolean active) {
return (root, query, cb) -> {
if (active == null) {
return cb.conjunction(); // no filter
}
return cb.equal(root.get("active"), active);
};
}
}
We return cb.conjunction() (always true) when the filter is not provided, so it can easily be combined with other specs.
6. Adding More Filters (Name, Category, Price Range, Rating)
6.1 Specifications for Strings and Ranges
public class ProductSpecifications {
public static Specification<Product> nameContains(String name) {
return (root, query, cb) -> {
if (name == null || name.isBlank()) {
return cb.conjunction();
}
return cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
};
}
public static Specification<Product> categoryNameEquals(String categoryName) {
return (root, query, cb) -> {
if (categoryName == null || categoryName.isBlank()) {
return cb.conjunction();
}
// join to Category entity
var categoryJoin = root.join("category");
return cb.equal(cb.lower(categoryJoin.get("name")), categoryName.toLowerCase());
};
}
public static Specification<Product> priceGreaterThanOrEqual(BigDecimal minPrice) {
return (root, query, cb) -> {
if (minPrice == null) {
return cb.conjunction();
}
return cb.greaterThanOrEqualTo(root.get("price"), minPrice);
};
}
public static Specification<Product> priceLessThanOrEqual(BigDecimal maxPrice) {
return (root, query, cb) -> {
if (maxPrice == null) {
return cb.conjunction();
}
return cb.lessThanOrEqualTo(root.get("price"), maxPrice);
};
}
public static Specification<Product> ratingGreaterThanOrEqual(Double minRating) {
return (root, query, cb) -> {
if (minRating == null) {
return cb.conjunction();
}
return cb.greaterThanOrEqualTo(root.get("rating"), minRating);
};
}
}
7. Combining Specifications
Specifications can be chained using and() / or().
7.1 Combine Filters
Specification<Product> spec = Specification
.where(ProductSpecifications.nameContains(name))
.and(ProductSpecifications.categoryNameEquals(categoryName))
.and(ProductSpecifications.priceGreaterThanOrEqual(minPrice))
.and(ProductSpecifications.priceLessThanOrEqual(maxPrice))
.and(ProductSpecifications.hasActive(active))
.and(ProductSpecifications.ratingGreaterThanOrEqual(minRating));
You can then pass this spec to the repository:
Page<Product> page = productRepository.findAll(spec, pageable);
8. Creating a Filter DTO for Clean Controllers
8.1 ProductFilterRequest.java
package com.example.specdemo.dto;
import lombok.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProductFilterRequest {
private String name;
private String category;
private BigDecimal minPrice;
private BigDecimal maxPrice;
private Double minRating;
private Boolean active;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime createdAfter;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime createdBefore;
}
8.2 Add Created Date Filters
public static Specification<Product> createdAfter(LocalDateTime after) {
return (root, query, cb) -> {
if (after == null) return cb.conjunction();
return cb.greaterThanOrEqualTo(root.get("createdAt"), after);
};
}
public static Specification<Product> createdBefore(LocalDateTime before) {
return (root, query, cb) -> {
if (before == null) return cb.conjunction();
return cb.lessThanOrEqualTo(root.get("createdAt"), before);
};
}
9. Service Layer — Build Specification + Sorting + Pagination
9.1 ProductService.java
package com.example.specdemo.service;
import com.example.specdemo.dto.ProductFilterRequest;
import com.example.specdemo.entity.Product;
import com.example.specdemo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.List;
import static com.example.specdemo.spec.ProductSpecifications.*;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> search(ProductFilterRequest filter,
int page,
int size,
String sortBy,
String direction) {
Specification<Product> spec = Specification
.where(nameContains(filter.getName()))
.and(categoryNameEquals(filter.getCategory()))
.and(priceGreaterThanOrEqual(filter.getMinPrice()))
.and(priceLessThanOrEqual(filter.getMaxPrice()))
.and(ratingGreaterThanOrEqual(filter.getMinRating()))
.and(hasActive(filter.getActive()))
.and(createdAfter(filter.getCreatedAfter()))
.and(createdBefore(filter.getCreatedBefore()));
Sort sort = buildSort(sortBy, direction);
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(spec, pageable);
}
private Sort buildSort(String sortBy, String direction) {
// default sort field
String sortField = (sortBy == null || sortBy.isBlank()) ? "id" : sortBy;
List<String> allowedSortFields = List.of("id", "name", "price", "rating", "createdAt");
if (!allowedSortFields.contains(sortField)) {
sortField = "id"; // prevent invalid column sort
}
boolean isDesc = direction != null && direction.equalsIgnoreCase("desc");
return isDesc ? Sort.by(sortField).descending() : Sort.by(sortField).ascending();
}
}
10. REST Controller — Sorting & Filtering API
10.1 ProductController.java
package com.example.specdemo.controller;
import com.example.specdemo.dto.ProductFilterRequest;
import com.example.specdemo.entity.Product;
import com.example.specdemo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<Page<Product>> searchProducts(
ProductFilterRequest filter, // populated from query params
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id") String sortBy,
@RequestParam(defaultValue = "asc") String direction
) {
Page<Product> result = productService.search(filter, page, size, sortBy, direction);
return ResponseEntity.ok(result);
}
}
ProductFilterRequest since it’s a simple POJO.
This keeps the controller method clean and readable.
11. Example Requests (cURL / HTTP)
11.1 Search “phone” in category “electronics”, price 10k–50k, active products only, sort by price desc
GET http://localhost:8080/api/products?name=phone&category=electronics&minPrice=10000&maxPrice=50000&active=true&sortBy=price&direction=desc
11.2 Products created in last week with rating >= 4.0
GET http://localhost:8080/api/products?minRating=4.0&createdAfter=2025-11-23T00:00:00
11.3 First page, 5 items per page, sorted by createdAt desc
GET http://localhost:8080/api/products?page=0&size=5&sortBy=createdAt&direction=desc
12. Building a More Generic Specification Builder (Optional Advanced)
Sometimes you want to build filters from UI dynamically, like:
field, operation, value triples. Let’s see a common pattern.
12.1 SearchOperation Enum
package com.example.specdemo.spec;
public enum SearchOperation {
EQUALITY, NEGATION,
GREATER_THAN, LESS_THAN,
LIKE, STARTS_WITH, ENDS_WITH,
IN, NOT_IN
}
12.2 SearchCriteria.java
package com.example.specdemo.spec;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class SearchCriteria {
private String key;
private SearchOperation operation;
private Object value;
}
12.3 GenericSpecification.java
package com.example.specdemo.spec;
import com.example.specdemo.entity.Product;
import jakarta.persistence.criteria.*;
import org.springframework.data.jpa.domain.Specification;
public class GenericSpecification implements Specification<Product> {
private final SearchCriteria criteria;
public GenericSpecification(SearchCriteria criteria) {
this.criteria = criteria;
}
@Override
public Predicate toPredicate(Root<Product> root,
CriteriaQuery<?> query,
CriteriaBuilder cb) {
switch (criteria.getOperation()) {
case EQUALITY:
return cb.equal(root.get(criteria.getKey()), criteria.getValue());
case GREATER_THAN:
return cb.greaterThan(root.get(criteria.getKey()), criteria.getValue().toString());
case LESS_THAN:
return cb.lessThan(root.get(criteria.getKey()), criteria.getValue().toString());
case LIKE:
return cb.like(cb.lower(root.get(criteria.getKey())),
"%" + criteria.getValue().toString().toLowerCase() + "%");
case STARTS_WITH:
return cb.like(cb.lower(root.get(criteria.getKey())),
criteria.getValue().toString().toLowerCase() + "%");
case ENDS_WITH:
return cb.like(cb.lower(root.get(criteria.getKey())),
"%" + criteria.getValue().toString().toLowerCase());
default:
return cb.conjunction();
}
}
}
12.4 Specification Builder
package com.example.specdemo.spec;
import com.example.specdemo.entity.Product;
import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.List;
public class ProductSpecBuilder {
private final List<SearchCriteria> params = new ArrayList<>();
public ProductSpecBuilder with(String key, SearchOperation op, Object value) {
if (value != null) {
params.add(new SearchCriteria(key, op, value));
}
return this;
}
public Specification<Product> build() {
if (params.isEmpty()) {
return null;
}
Specification<Product> result = new GenericSpecification(params.get(0));
for (int i = 1; i < params.size(); i++) {
result = result.and(new GenericSpecification(params.get(i)));
}
return result;
}
}
12.5 Usage in Service
public Page<Product> searchDynamic(ProductFilterRequest filter,
int page,
int size,
String sortBy,
String direction) {
ProductSpecBuilder builder = new ProductSpecBuilder()
.with("name", SearchOperation.LIKE, filter.getName())
.with("price", SearchOperation.GREATER_THAN, filter.getMinPrice())
.with("price", SearchOperation.LESS_THAN, filter.getMaxPrice())
.with("rating", SearchOperation.GREATER_THAN, filter.getMinRating())
.with("active", SearchOperation.EQUALITY, filter.getActive());
Specification<Product> spec = builder.build();
Sort sort = buildSort(sortBy, direction);
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(spec, pageable);
}
13. Common Pitfalls & How to Avoid Them
| Problem | Cause | Fix |
|---|---|---|
| Queries become very slow | Too many filters, no DB indexes | Create indexes on frequently filtered/sorted fields (e.g. price, active, category_id) |
| Invalid sortBy breaks API | User passes unknown column name | Use whitelist for allowed sort fields, fallback to id |
| Unexpected empty results | Using AND when OR was intended |
Carefully design Specification combination logic |
| Time zone confusion with date filters | Client/server in different zones | Standardise on UTC and ISO-8601 formats |
| N+1 queries when fetching relationships | Lazy loading of categories in lists | Use join fetch or projection DTOs if needed |
14. When to Use Specifications vs Other Approaches
- Use JPA Specifications when:
- Filters are dynamic and mostly AND/OR combinations
- You want type-safe criteria instead of string-based JPQL
- You already use Spring Data JPA
- Use custom JPQL/Native queries when:
- The query is very complex or vendor-specific
- You need special DB functions not easily expressed via criteria
- Use Querydsl / Criteria API directly when:
- You want more advanced composition and static metamodels
15. Summary
In this guide, we built a robust filtering and sorting system using Spring Boot + JPA Specifications:
- Defined Product and Category entities
- Enabled
JpaSpecificationExecutorin the repository - Created reusable Specification methods for:
- String contains (LIKE)
- Price and rating ranges
- Boolean filters
- Date ranges
- Category join filters
- Combined filters dynamically with
and() - Added sorting + pagination with field whitelisting
- Explored a generic Specification builder pattern
- Reviewed performance and correctness best practices
You can now extend this pattern to any entity in your system — users, orders, transactions — and expose powerful search APIs with very little additional code.