JPA Specification Implementation - VittorioDeMarzi/hero-beans GitHub Wiki
The JPA Specification pattern is implemented to create dynamic, reusable queries for filtering coffee products based on various criteria. This approach avoids writing multiple repository methods for each combination of filters and instead builds queries programmatically.
The implementation is broken down into three main parts:
-
Individual Specifications (
CoffeeSpecs
) -
Specification Builder (
SpecificationBuilder
) -
Service Layer Integration (
CoffeeService
)
The process begins in the GuestController
at the /api/product
endpoint. This endpoint accepts several optional request parameters for pagination, sorting, and filtering.
The filtering parameters are collected into a single data transfer object (DTO) called CoffeeFilterCriteria
. This object centralizes all possible filter options.
// File: GuestController.kt
@Operation(summary = "List all coffees")
@GetMapping()
fun getAll(
// ... pagination and sorting params
// Filtering parameters
@RequestParam(required = false) name: String?,
@RequestParam(required = false) origin: OriginCountry?,
@RequestParam(required = false) roastLevel: RoastLevel?,
@RequestParam(required = false) brewRecommendation: Set<BrewRecommendation?>?,
@RequestParam(required = false) processingMethod: ProcessingMethod?,
@RequestParam(required = false, defaultValue = "false") availableOnly: Boolean,
// ... sorting param
): ResponseEntity<Page<CoffeeDto>> {
// The filter parameters are wrapped in a criteria object.
val filterCriteria =
CoffeeFilterCriteria(
name = name,
originCountry = origin,
roastLevel = roastLevel,
brew = brewRecommendation,
processingMethod = processingMethod,
availableOnly = availableOnly,
)
// The criteria object is passed to the service layer.
val products = coffeeService.getAllProducts(page, size, filterCriteria, sort)
return ResponseEntity.ok(products)
}
The CoffeeService
receives the filterCriteria
object. It then passes this object to the SpecificationBuilder
, which constructs the final query specification. This specification is executed by the CoffeeJpaRepository
.
The repository can execute these specifications because it extends JpaSpecificationExecutor<Coffee>
.
// File: CoffeeService.kt
@Transactional(readOnly = true)
fun getAllProducts(
page: Int,
size: Int,
filterCriteria: CoffeeFilterCriteria,
sort: CoffeeSorting,
): Page<CoffeeDto> {
val sort = toSpringSort(sort)
val pageable = PageRequest.of(page, size, sort)
// The criteria object is used to build the specification.
val spec = SpecificationBuilder.buildSpecification(filterCriteria)
// The repository finds all entries matching the dynamic specification.
return coffeeJpaRepository.findAll(spec, pageable).map { it.toDto() }
//...
}
// File: CoffeeJpaRepository.kt
// JpaSpecificationExecutor provides the API for executing Specifications.
interface CoffeeJpaRepository : JpaRepository<Coffee, Long>, JpaSpecificationExecutor<Coffee> {
fun existsByName(name: String): Boolean
}
The SpecificationBuilder
is responsible for constructing a composite Specification<Coffee>
object. It checks which fields in the CoffeeFilterCriteria
object are present (not null
) and dynamically chains the corresponding specifications together using the .and()
operator.
If a filter criterion is not provided, it is simply ignored, and no condition is added to the query for that criterion.
// File: SpecificationBuilder.kt
object SpecificationBuilder {
fun buildSpecification(criteria: CoffeeFilterCriteria): Specification<Coffee> {
// Start with an empty specification that does not restrict results.
var spec = Specification.unrestricted<Coffee>()
// Conditionally add specifications based on the provided criteria.
criteria.name?.let {
spec = spec.and(CoffeeSpecs.hasNameContaining(it))
}
criteria.brew?.let {
spec = spec.and(CoffeeSpecs.hasBrewRecommendation(it.filterNotNull().toSet()))
}
criteria.originCountry?.let {
spec = spec.and(CoffeeSpecs.hasOrigin(it))
}
criteria.processingMethod?.let {
spec = spec.and(CoffeeSpecs.hasProcessingMethod(it))
}
criteria.roastLevel?.let {
spec = spec.and(CoffeeSpecs.hasRoastLevel(it))
}
// This specification is always added.
criteria.availableOnly.let {
spec = spec.and(CoffeeSpecs.isAvailable(it))
}
return spec
}
}
The CoffeeSpecs
object contains the individual, reusable building blocks for the query. Each function in this object returns a Specification<Coffee>
, which represents a single predicate (a WHERE
clause condition) in the final SQL query.
This separation makes the filter logic clean, testable, and easy to reuse.
Here are some examples of the individual specifications:
// File: CoffeeSpecs.kt
object CoffeeSpecs {
// Corresponds to: WHERE lower(name) LIKE '%<name>%'
fun hasNameContaining(name: String): Specification<Coffee> {
return Specification { root, _, cb ->
cb.like(cb.lower(root.get("name")), "%${name.lowercase()}%")
}
}
// Corresponds to: WHERE origin = '<origin>'
fun hasOrigin(origin: OriginCountry): Specification<Coffee> {
return Specification { root, _, cb ->
cb.equal(root.get<OriginCountry>("origin"), origin)
}
}
// Corresponds to: WHERE brewRecommendation IN ('<rec1>', '<rec2>', ...)
fun hasBrewRecommendation(brewRecs: Set<BrewRecommendation>): Specification<Coffee> {
return Specification { root, _, cb ->
root.get<BrewRecommendation>("brewRecommendation").`in`(brewRecs)
}
}
// Corresponds to: WHERE isAvailable = true
fun isAvailable(availableOnly: Boolean): Specification<Coffee> {
return Specification { root, _, cb ->
if (availableOnly) {
cb.isTrue(root.get("isAvailable"))
} else {
// Returns an empty condition, effectively doing nothing.
cb.conjunction()
}
}
}
}