If you've ever built a Spring Boot API with Spring Data JPA, you know that Hibernate is like an incredibly fast, highly skilled barista. 99% of the time, it hands you the exact espresso you asked for before you even finish blinking.

But then, there's that 1%. The day you ask for a seemingly simple custom order, and the barista just stares at you in dead silence, handing you an empty cup while claiming everything went perfectly.

This is the story of how a simple "Filter by Flavor" feature brought our trendy virtual cafe to its knees, and the two absurd traps we had to escape to fix it.

None
Photo by Manki Kim on Unsplash

Phase 1: The Giant Comma Monster

Our cafe database was booming. We had hundreds of CoffeeOrder records, each with a @ElementCollection of FlavorNotes (like "Caramel", "Nutty", "Fruity").

We wanted an endpoint where a customer could filter coffees by multiple flavors: GET /api/coffees?flavors=Caramel,Nutty

Our Cashier (the Spring Boot Controller) was supposed to take that request and hand a nice, neat List of strings to the Barista (the Repository). We even wrote what we thought was a bulletproof JPQL query to handle it:

@Query("""
    SELECT DISTINCT c FROM CoffeeOrder c 
    LEFT JOIN c.flavorNotes f
    WHERE (:flavors IS NULL OR LOWER(f) IN :flavors)
""")
fun findByFlavors(@Param("flavors") flavors: List<String>?): List<CoffeeOrder>

We fired up Postman, sent ?flavors=Caramel,Nutty, and got... []. An empty array. Zero coffees.

The Diagnosis: We assumed Spring Boot would look at Caramel,Nutty and automatically hand the Barista a list of two items: ["Caramel", "Nutty"].

Instead, it handed the Barista a list containing exactly one giant, mutated item: ["Caramel,Nutty"]. The database diligently searched the shelves for a single coffee bean genetically modified to taste like a comma-separated string, found nothing, and returned zero results.

The Fix: We had to teach our Cashier how to split strings properly, trim the whitespace, and make everything lowercase so "nutty" matches "Nutty".

// Inside the Controller
val safeFlavors = flavors
    ?.flatMap { it.split(",") }
    ?.map { it.trim().lowercase() }
    ?.filter { it.isNotBlank() }
    ?.takeIf { it.isNotEmpty() }

We ran it again. The console logs proved the Cashier was now handing over a perfect, beautifully formatted list: ["caramel", "nutty"].

We hit Send in Postman.

[].

Still zero coffees.

Phase 2: The Silent Treatment (The Hibernate 6 Bug)

Our app wasn't crashing. We weren't getting 500 Internal Server Error. The database query was executing flawlessly—it was just aggressively deciding that no coffees matched our request.

The culprit was hiding in plain sight in our JPQL query: (:flavors IS NULL OR LOWER(f) IN :flavors)

This looks like standard SQL logic. If the customer didn't provide any flavors, :flavors is null, the first half of the ORstatement evaluates to TRUE, and it returns all the coffees. Right?

Wrong.

When you pass a Kotlin List (a Collection) into a JPA query, Hibernate sometimes loses its mind when it hits that IS NULL check. Instead of unpacking the list like a normal database parameter, it panics and binds the entire collection as a single, bizarre Postgres array object (something like "{caramel, nutty}").

Postgres looks at that syntax, shrugs, says "I don't have a flavor called literally open-curly-brace-caramel-comma-nutty-close-curly-brace", and returns zero rows. No errors. Just pure, unadulterated gaslighting.

The Bulletproof Fix: The Boolean Toggle

We couldn't use IS NULL on a collection. We needed a way to completely bypass the IN clause if the customer didn't want to filter by flavors, without angering Hibernate.

The solution? We gave the Barista a simple Yes/No switch.

Step 1: The Repository Update We replaced IS NULL with a boolean flag called applyFlavorFilter.

@Query("""
    SELECT DISTINCT c FROM CoffeeOrder c 
    LEFT JOIN c.flavorNotes f
    WHERE (:applyFlavorFilter = false OR LOWER(f) IN :flavors)
""")
fun findByFlavors(
    @Param("applyFlavorFilter") applyFlavorFilter: Boolean, 
    @Param("flavors") flavors: List<String> // Strictly non-null now!
): List<CoffeeOrder>

Step 2: The Controller Update In the controller, we evaluate the boolean. If the user didn't pass any flavors, we set the boolean to false and pass a dummy list (like ["DUMMY_BEAN"]) just so the IN clause doesn't crash on an empty array.

val applyFlavorFilter = safeFlavors != null
val queryFlavors = safeFlavors ?: listOf("DUMMY_BEAN")

coffeeRepository.findByFlavors(
    applyFlavorFilter = applyFlavorFilter,
    flavors = queryFlavors
)

Why this is a superpower: Short-Circuiting

Relational databases are lazy in the best way possible. When Postgres evaluates (:applyFlavorFilter = false OR LOWER(f) IN :flavors), and it sees that applyFlavorFilter is false, it says: "Well, the first half of this OR statement is TRUE, so I don't even need to read the second half!"

It completely ignores our DUMMY_BEAN list, skips the IN clause entirely, and instantly serves up the entire menu of coffees.

And just like that, the cafe was open for business again.