Filtering with Specification - VittorioDeMarzi/hero-beans GitHub Wiki

JPA Specifications Documentation - HeroBeans Coffee Project

What is JPA Specification?

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

Key Concepts:

  • 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(), and not() methods

  • Reusability: Individual specifications can be reused across different queries

Implementation in Our Coffee Project

Architecture Overview

Our implementation follows a clean, modular approach with three main components:


GuestController → CoffeeService → CoffeeJpaRepository + Specifications

1. Repository Layer (CoffeeJpaRepository)

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)

2. Specification Definitions (CoffeeSpecs)

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

3. Specification Builder (SpecificationBuilder)

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)

4. Service Layer Integration (CoffeeService)

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

}

5. Controller Layer (GuestController)

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

Why We Chose Specifications Over JPA Queries

1. Dynamic Query Building

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.

2. Combinatorial Explosion Prevention

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.

3. Type Safety

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

4. Reusability and Maintainability

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

5. Complex Query Support

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

6. Performance Benefits

  • 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

Comparison: Specifications vs Alternatives

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

Example Usage Scenarios

Simple Filtering

GET /api/product?origin=BRAZIL&availableOnly=true

Generates: WHERE origin = 'BRAZIL' AND isAvailable = true

Complex Filtering

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

Pagination with Filtering

GET /api/product?page=2&size=10&origin=ETHIOPIA&availableOnly=true

Efficiently retrieves page 2 (items 21-30) with filtering applied.

Benefits Summary

  1. Maintainability: Single point of query logic modification

  2. Scalability: Easy to add new filter criteria without repository changes

  3. Testability: Individual specifications can be unit tested

  4. Performance: Optimal database queries with proper indexing support

  5. Flexibility: Supports complex filtering scenarios with minimal code changes

  6. Type Safety: Compile-time verification of query correctness

Best Practices

  1. Keep specifications atomic: Each specification should handle one filtering concern

  2. Use meaningful names: Specification names should clearly indicate their purpose

  3. Handle null values: Always check for null criteria before applying specifications

  4. Combine wisely: Use and(), or(), and not() judiciously for readable logic

  5. 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.

⚠️ **GitHub.com Fallback** ⚠️