QueryDSL
QueryDSL is a fluent, typesafe query API. Instead of building queries from strings (JPQL) or verbose CriteriaBuilder calls, you write predicates against generated metamodel classes — the Q-classes — so column names, types, and operators are checked by the compiler. Rename a field and your queries stop compiling rather than failing at runtime.
It plugs directly into Spring Data through QuerydslPredicateExecutor, making it an ergonomic alternative to Specifications for dynamic, runtime-composed filters.
Dependencies and the APT processor
QueryDSL ships a separate annotation processor that scans your @Entity classes and generates a Q<Entity> companion for each one. Add the JPA artifact (Jakarta classifier on Spring Boot 3) plus the processor:
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<classifier>jakarta</classifier>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<classifier>jakarta</classifier>
<scope>provided</scope>
</dependency>
Wire the processor into the Maven compile phase so Q-classes are generated into target/generated-sources:
<plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>1.1.3</version>
<executions>
<execution>
<goals><goal>process</goal></goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</configuration>
</execution>
</executions>
</plugin>
Tip: Run
mvn compile(or your IDE’s build) once after adding the entity. Until the Q-classes are generated,QProductwill not exist and your editor will flag it as missing.
For an entity:
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private String category;
private BigDecimal price;
// getters/setters
}
…the processor emits QProduct with a typed field per attribute: QProduct.product.name, QProduct.product.price, and so on.
QuerydslPredicateExecutor on the repository
Extend QuerydslPredicateExecutor<T> alongside JpaRepository:
public interface ProductRepository
extends JpaRepository<Product, Long>, QuerydslPredicateExecutor<Product> {
}
This adds predicate-driven methods to the repository:
Optional<T> findOne(Predicate predicate);
Iterable<T> findAll(Predicate predicate);
Page<T> findAll(Predicate predicate, Pageable pageable);
Iterable<T> findAll(Predicate predicate, Sort sort);
long count(Predicate predicate);
boolean exists(Predicate predicate);
Building BooleanExpression predicates
BooleanExpression is the typesafe predicate type. The fluent operators (eq, loe, containsIgnoreCase, and, or) read like the filter itself:
QProduct product = QProduct.product;
BooleanExpression byCategory = product.category.eq("books");
BooleanExpression cheap = product.price.loe(new BigDecimal("50"));
List<Product> result = (List<Product>) repository.findAll(byCategory.and(cheap));
The real strength is conditional composition — append a clause only when its filter is supplied, with no string concatenation:
public record ProductFilter(String category, BigDecimal maxPrice, String name) {}
@Service
public class ProductSearchService {
private final ProductRepository repository;
public ProductSearchService(ProductRepository repository) {
this.repository = repository;
}
public Page<Product> search(ProductFilter filter, Pageable pageable) {
QProduct product = QProduct.product;
BooleanBuilder where = new BooleanBuilder();
if (filter.category() != null) {
where.and(product.category.eq(filter.category()));
}
if (filter.maxPrice() != null) {
where.and(product.price.loe(filter.maxPrice()));
}
if (filter.name() != null && !filter.name().isBlank()) {
where.and(product.name.containsIgnoreCase(filter.name()));
}
return repository.findAll(where, pageable);
}
}
BooleanBuilder is a mutable accumulator; an empty builder matches everything, so an empty filter returns all rows (subject to paging). For a category + max-price filter the generated SQL contains only the active predicates:
select p1_0.id, p1_0.category, p1_0.name, p1_0.price
from product p1_0
where p1_0.category = ? and p1_0.price <= ?
order by p1_0.name asc
offset ? rows fetch first ? rows only
QueryDSL vs Specifications / Criteria API
All three build dynamic queries, but they differ sharply in type safety and ergonomics:
| Aspect | QueryDSL | JPA Specifications | Criteria API (raw) |
|---|---|---|---|
| Field references | Generated Q-classes (compile-checked) | root.get("name") strings | root.get("name") strings |
| Readability | Fluent, query-like | Moderate (lambda fragments) | Verbose, ceremony-heavy |
| Build step | Requires APT codegen | None | None |
| Reusable fragments | BooleanExpression constants | Specification factories | Manual |
| Refactor safety | High — rename breaks compile | Low — strings break at runtime | Low |
| Extra dependency | Yes (querydsl-jpa) | No (Spring Data built-in) | No (JPA built-in) |
Note: Specifications can use the JPA static metamodel (
Product_.price) for some compile-time safety, but QueryDSL’s generated API is more complete and the fluent syntax is generally easier to read for complex predicates.
Tip: Choose QueryDSL when you have many dynamic search screens and value readable, refactor-safe queries enough to accept the codegen step. Stick with Specifications when you want zero extra build configuration.
Pitfalls
- Forgotten codegen: if
QProductwon’t resolve, the APT processor hasn’t run — rebuild and ensuretarget/generated-sources/javais a source root. - Joins and
distinct: for@OneToManyjoins useJPAQuerydirectly with.distinct()to avoid duplicate roots; the simple predicate executor doesn’t expose that knob. - Beyond predicates:
QuerydslPredicateExecutoronly filters/sorts/pages. For projections, group-by, or aggregates, inject aJPAQueryFactoryand write the full query.