Filtering with Specification - VittorioDeMarzi/hero-beans GitHub Wiki
JPA Specification is a powerful feature provided by Spring Data JPA that implements the Specification Pattern for building dynamic, type-safe, and composable database queries. It allows you to create reusable query conditions that can be combined programmatically using logical operators (AND, OR, NOT).
-
Specification: A functional interface that defines a single method
toPredicate()
which returns a JPA Criteria API predicate -
Criteria API: JPA's programmatic query construction API that provides type safety and dynamic query building
-
Composability: Specifications can be combined using
and()
,or()
, andnot()
methods -
Reusability: Individual specifications can be reused across different queries
Our implementation follows a clean, modular approach with three main components:
GuestController → CoffeeService → CoffeeJpaRepository + Specifications
interface CoffeeJpaRepository : JpaRepository<Coffee, Long>, JpaSpecificationExecutor<Coffee>
The repository extends JpaSpecificationExecutor<Coffee>
which provides methods like:
-
findAll(Specification<Coffee> spec, Pageable pageable)
-
findAll(Specification<Coffee> spec)
-
count(Specification<Coffee> spec)
Individual specifications are defined as static methods that return Specification<Coffee>
:
object CoffeeSpecs {
fun hasNameContaining(name: String): Specification<Coffee> {
return Specification { root, _, cb ->
cb.like(cb.lower(root.get("name")), "%${name.lowercase()}%")
}
}
fun hasOrigin(origin: OriginCountry): Specification<Coffee> {
return Specification { root, _, cb ->
cb.equal(root.get<OriginCountry>("origin"), origin)
}
}
// ... other specifications
}
Key Points:
-
Each specification focuses on a single filtering concern
-
Type safety is maintained through generics
-
Case-insensitive search for text fields
-
Direct enum comparison for categorical fields
The builder pattern combines multiple specifications based on filter criteria:
object SpecificationBuilder {
fun buildSpecification(criteria: CoffeeFilterCriteria): Specification<Coffee> {
var spec = Specification.unrestricted<Coffee>()
criteria.name?.let {
spec = spec.and(CoffeeSpecs.hasNameContaining(it))
}
criteria.brew?.let {
spec = spec.and(CoffeeSpecs.hasBrewRecommendation(it.filterNotNull().toSet()))
}
// ... other criteria
return spec
}
}
Features:
-
Starts with an unrestricted specification (equivalent to no WHERE clause)
-
Conditionally adds specifications using
and()
operations -
Handles nullable filter criteria gracefully
-
Filters out null values from collections (e.g.,
filterNotNull()
for brew recommendations)
fun getAllProducts(
page: Int,
size: Int,
filterCriteria: CoffeeFilterCriteria,
sort: CoffeeSorting,
): Page<CoffeeDto> {
val sort = toSpringSort(sort)
val pageable = PageRequest.of(page, size, sort)
val spec = SpecificationBuilder.buildSpecification(filterCriteria)
return coffeeJpaRepository.findAll(spec, pageable).map { it.toDto() }
}
The controller accepts multiple optional filter parameters and delegates to the service:
@GetMapping()
fun getAll(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "8") size: Int,
@RequestParam(required = false) name: String?,
@RequestParam(required = false) origin: OriginCountry?,
// ... other filter parameters
): ResponseEntity<Page<CoffeeDto>>
Problem with Static JPA Queries:
// You'd need separate methods for each combination
@Query("SELECT c FROM Coffee c WHERE c.name LIKE %:name%")
fun findByNameContaining(name: String): List<Coffee>
@Query("SELECT c FROM Coffee c WHERE c.origin = :origin")
fun findByOrigin(origin: OriginCountry): List<Coffee>
@Query("SELECT c FROM Coffee c WHERE c.name LIKE %:name% AND c.origin = :origin")
fun findByNameContainingAndOrigin(name: String, origin: OriginCountry): List<Coffee>
Specification Solution:
With specifications, we build queries dynamically based on provided criteria, eliminating the need for multiple repository methods.
Our coffee filtering has 6 different criteria:
-
Name (String)
-
Origin Country (Enum)
-
Roast Level (Enum)
-
Brew Recommendations (Set of Enums)
-
Processing Method (Enum)
-
Available Only (Boolean)
With static queries, we'd need 2^6 = 64 different methods to cover all combinations. With specifications, we have 1 flexible method that handles all scenarios.
Unlike string-based @Query
annotations, specifications provide:
-
Compile-time type checking: Errors are caught during compilation
-
IDE support: Auto-completion and refactoring work correctly
-
Refactoring safety: Field renames are automatically reflected
// Specifications can be reused across different contexts
val availableCoffees = CoffeeSpecs.isAvailable(true)
val brazilianCoffees = CoffeeSpecs.hasOrigin(OriginCountry.BRAZIL)
// Easy to combine for complex queries
val popularBrazilianCoffees = availableCoffees.and(brazilianCoffees)
Specifications excel at complex scenarios:
-
Nested filtering: Working with related entities
-
OR conditions:
spec1.or(spec2)
-
NOT conditions:
Specification.not(spec)
-
Custom predicates: Full access to Criteria API for complex logic
-
Single query execution: All filters are applied in one database query
-
Efficient pagination: Works seamlessly with Spring's
Pageable
-
Optimized SQL: JPA generates optimized SQL based on the criteria
Approach | Dynamic Queries | Type Safety | Complexity | Performance |
---|---|---|---|---|
JPA Specifications | ✅ Excellent | ✅ Full | 🟡 Medium | ✅ Optimal |
Static @Query methods | ❌ Poor | 🟡 Limited | ✅ Simple | ✅ Good |
Criteria API directly | ✅ Excellent | ✅ Full | ❌ High | ✅ Optimal |
Query by Example | 🟡 Limited | ✅ Full | ✅ Simple | 🟡 Good |
GET /api/product?origin=BRAZIL&availableOnly=true
Generates: WHERE origin = 'BRAZIL' AND isAvailable = true
GET /api/product?name=arabica&roastLevel=MEDIUM&brewRecommendation=ESPRESSO,FRENCH_PRESS&sort=PRICE_ASC
Generates: WHERE LOWER(name) LIKE '%arabica%' AND roastLevel = 'MEDIUM' AND brewRecommendation IN ('ESPRESSO', 'FRENCH_PRESS') ORDER BY price ASC, id ASC
GET /api/product?page=2&size=10&origin=ETHIOPIA&availableOnly=true
Efficiently retrieves page 2 (items 21-30) with filtering applied.
-
Maintainability: Single point of query logic modification
-
Scalability: Easy to add new filter criteria without repository changes
-
Testability: Individual specifications can be unit tested
-
Performance: Optimal database queries with proper indexing support
-
Flexibility: Supports complex filtering scenarios with minimal code changes
-
Type Safety: Compile-time verification of query correctness
-
Keep specifications atomic: Each specification should handle one filtering concern
-
Use meaningful names: Specification names should clearly indicate their purpose
-
Handle null values: Always check for null criteria before applying specifications
-
Combine wisely: Use
and()
,or()
, andnot()
judiciously for readable logic -
Test thoroughly: Unit test individual specifications and their combinations
This approach has made our coffee filtering system robust, maintainable, and performant while keeping the codebase clean and extensible.