DEV Community

torshid
torshid

Posted on

Type-Safe Query Builders for JPA: No Strings, No Typos

String-based field references are the silent killer of JPA projects. They compile. They run. Then someone renames unitPrice to price and your /search?filter=unitPrice > 100 endpoint returns an empty result set forever. No error. No stacktrace. Just... nothing. Users think there's no data. You think the query is broken. Nobody knows what's actually happening until someone digs through logs and finds the Criteria API silently ignoring a field that no longer exists.

I've been bitten by this. Multiple times. On different projects. Same exact bug every time because "it's just a search endpoint, I'll refactor it later" and then you never do.

Spring Filter has an annotation processor that solves this. You annotate your entity with @Filterable, it generates a fluent builder class at compile time, and suddenly you can't reference fields that don't exist because the compiler won't let you.

Before: the string soup

Let's say you have a Car entity. You're building a search API and want to let users filter by year, brand name, mileage, and color. Here's how most people do it with Spring Filter's builder (which is already nicer than raw CriteriaBuilder, but still string-based):

FilterNode filter = fb.field("year")
    .greaterThan(fb.input(2020))
    .and(fb.field("brand.name")
        .in(fb.collection(fb.input("audi"), fb.input("bmw"))))
    .and(fb.field("km")
        .lessThan(fb.input(50000)))
    .and(fb.field("color")
        .equal(fb.input(Color.RED)))
    .get();
Enter fullscreen mode Exit fullscreen mode

This works. It compiles. If you rename brand to manufacturer on the entity, this code doesn't break. It runs. It just... silently does nothing. The parser won't find a field called brand.name in the entity metadata and it'll throw some kind of error at parse time -- but only when a user actually sends that filter. The builder code above (which constructs the AST programmatically) might or might not fail depending on how the transformer resolves unknown fields. Point is: there's no compile-time safety.

After: the generated builder

Step 1: annotate your entity. Nothing special, just one import:

import com.turkraft.springfilter.typesafe.Filterable;

@Entity
@Filterable
public class Car {
    @Id private Long id;
    private int year;
    private int km;
    private String model;
    private boolean active;
    @Enumerated private Color color;
    @ManyToOne private Brand brand;
    @OneToMany private List<Accident> accidents;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: add the annotation processor to your Maven compiler plugin:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>com.turkraft.springfilter</groupId>
                <artifactId>typesafe-processor</artifactId>
                <version>4.0.4</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

Step 3: compile. The processor generates CarFilter.java in your build output.

Step 4: use it:

@Autowired FilterBuilder fb;

FilterNode f = CarFilter.where(fb)
    .year().between(2020, 2025)
    .and()
    .model().startsWith("Audi")
    .and()
    .brand().name().equal("audi")
    .and()
    .active().isTrue()
    .build();
Enter fullscreen mode Exit fullscreen mode

Now if you rename model to carModel on the entity, this code won't compile. CarFilter gets regenerated with the new field names and the startsWith("Audi") line now has no matching method. Your IDE underlines it in red before you even hit save. You fix it immediately. This is the whole point.

What fields get generated?

The processor walks your entity's fields and their types and generates appropriate step classes:

  • int, long -> IntFieldStep (between, greaterThan, lessThan, equal, etc.)
  • double, BigDecimal -> DoubleFieldStep (same range ops)
  • boolean -> BooleanFieldStep (isTrue, isFalse)
  • String -> StringFieldStep (startsWith, endsWith, contains, like, equal)
  • Date, LocalDate, LocalDateTime -> DateFieldStep (after, before, between)
  • Enums -> EnumFieldStep (equal, in, notIn)
  • Collections (List, Set) -> CollectionFieldStep (size, isEmpty, isNotEmpty)
  • Entity references (@ManyToOne) -> a nested builder for that entity's fields

Fields annotated with @Transient or @JsonIgnore are skipped. Inherited fields from superclasses are included. If you have an @ElementCollection of primitives, it'll generate appropriate collection operations.

The generated code isn't some reflection black box either. You can read it. It's in your target/generated-sources directory. It's just Java classes that delegate to the filter builder. No runtime reflection, no proxy objects, nothing that would confuse your debugger.

Nested relations work out of the box

One of the things I'm kind of proud of: nested entity traversal. If Car has a Brand and Brand has a Manufacturer, you get:

CarFilter.where(fb)
    .brand().manufacturer().country().equal("germany")
    .and()
    .year().greaterThan(2020)
    .build();
Enter fullscreen mode Exit fullscreen mode

Every level of nesting generates another inner builder. The processor handles @ManyToOne, @OneToOne, and embedded objects. It stops at collections (you can't chain into elements of a @OneToMany, though you can filter on collection size).

What about compile time?

The processor runs during javac. For a typical entity with 20 fields, it adds maybe 100ms to your build. It generates about 300-500 lines of Java across a handful of classes. You'll only notice it if you have hundreds of @Filterable entities, and even then, incremental compilation means it only re-processes entities that changed.

The limitation: you're locked to the entity structure

Your filterable fields mirror your JPA entity exactly. If your API exposes a field called price but your entity calls it unitPrice, you need to alias it. Spring Filter's ParseContext (covered in the multi-tenancy article) handles this with a field mapper:

ParseContext ctx = new ParseContextImpl(field -> {
    if ("price".equals(field)) return "unitPrice";
    return field;
}, null);
Enter fullscreen mode Exit fullscreen mode

But the type-safe builder doesn't know about this alias. If you're using the generated builder AND field aliasing, you have a choice: either rename the entity field to match the API (preferred, if you can) or use the string-based builder for aliased fields and the type-safe builder for everything else.

Not ideal, but it's an edge case. Most projects I've seen either expose entity field names directly or have a clean enough naming convention that aliasing isn't needed.

Real world: before and after

Here's a bug I actually shipped in 2021. Entity had a field called createdAt. API docs said you could filter on createdDate because that's what the frontend team decided sounded better. We added a field mapper in ParseContext: "createdDate" -> "createdAt". Six months later someone refactored the entity, renamed createdAt to created, updated the field mapper... but forgot to update one raw @Query in a reporting endpoint that still referenced created_at (snake_case because MySQL).

Result: the reporting endpoint silently returned zero results for two weeks. Nobody noticed because the reports had low traffic.

With @Filterable, the refactor would have broken compilation. The generated CarFilter class would no longer have a createdAt() method. The mapper would still need updating, but the raw SQL query? If we were using the type-safe builder to generate filters and feeding them through the same converter, the field name change would have been caught at build time.

But we were mixing approaches. That's the real lesson: pick one consistency mechanism and stick with it. Don't half-use a type-safe builder.

Summary

The @Filterable processor gives you:

  • Compile-time field name validation
  • IDE autocomplete on every filterable field
  • Correctly typed comparison methods (no between() on a String field)
  • No runtime overhead (it's just Java code, generated once)
  • Works with your existing Maven/Gradle setup

What it costs:

  • An extra dependency (typesafe-processor)
  • An annotation on each entity you want to filter
  • 100ms of build time per entity (negligible)
  • You have to recompile when entity fields change (which you already do)

For me the trade is obvious. I've shipped too many silent bugs from string-based field references to ever go back. The annotation processor catches what code review misses, and it catches it before QA even gets the build.

Full setup docs at github.com/turkraft/springfilter. The processor is in the typesafe-processor module if you want to poke around.

Top comments (0)