JPA Specification Implementation - VittorioDeMarzi/hero-beans GitHub Wiki

JPA Specification Implementation

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:

  1. Individual Specifications (CoffeeSpecs)
  2. Specification Builder (SpecificationBuilder)
  3. Service Layer Integration (CoffeeService)

1. Controller: Receiving Filter Requests

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)
}

2. Service: Executing the Specification

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
}

3. Specification Builder: Assembling the Query

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
    }
}

4. Coffee Specs: Defining Individual Conditions

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() 
            }
        }
    }
}
⚠️ **GitHub.com Fallback** ⚠️