Spring Boot Sorting & Filtering with JPA Specifications (Advanced Guide)

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.

In this guide, we will build a production-grade filtering and sorting API using 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
Specifications are great when your filters are optional and dynamic based on query parameters or user-selected filters from a UI.

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;
}
This model is rich enough to demonstrate string search, numeric ranges, boolean filters, date ranges, and joins — all with Specifications.

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();
  }
}
Notice the whitelist of allowed sort fields. Never trust user input to directly map to a property name, otherwise you might expose internals or break the query.

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);
  }
}
Spring automatically binds query parameters to 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);
}
This pattern is useful when your filters are driven by dynamic UI forms or you want a compact generic approach.

13. Common Pitfalls & How to Avoid Them

ProblemCauseFix
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 JpaSpecificationExecutor in 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.