Once you’re comfortable with query methods and JPQL, the natural next step is dynamic queries with Specification. But as conditions multiply, you might start hitting its limits. That’s where QueryDSL comes in.

The Limits of Specification and Why QueryDSL

Stacking conditions with Specification leads to code like this:

Specification<User> spec = Specification.where(null);
if (name != null) spec = spec.and(hasName(name));
if (status != null) spec = spec.and(hasStatus(status));
if (from != null) spec = spec.and(createdAfter(from));

The code itself isn’t bad, but the lack of IDE completion is a real pain point. Field names are often handled as strings, so typos won’t cause compile errors. As conditions grow, maintainability declines.

QueryDSL lets you write queries in Java code, so type checking and completion work fully. Refactoring becomes worry-free.

Understanding How QueryDSL Works in 30 Seconds

QueryDSL uses APT (Annotation Processing Tool) to auto-generate “Q-type classes” from your entity classes.

If you have a User entity, QUser is generated, and you can write type-safe conditions like QUser.user.name.eq("Alice"). Because these are generated at build time, IDE completion works completely.

This article targets QueryDSL 5.1.0 and Spring Boot 3.x (Jakarta EE).

Maven Configuration

<dependencies>
    <dependency>
        <groupId>com.querydsl</groupId>
        <artifactId>querydsl-jpa</artifactId>
        <version>5.1.0</version>
        <classifier>jakarta</classifier>
    </dependency>
    <dependency>
        <groupId>com.querydsl</groupId>
        <artifactId>querydsl-apt</artifactId>
        <version>5.1.0</version>
        <classifier>jakarta</classifier>
        <scope>provided</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <annotationProcessorPaths>
                    <path>
                        <groupId>com.querydsl</groupId>
                        <artifactId>querydsl-apt</artifactId>
                        <version>5.1.0</version>
                        <classifier>jakarta</classifier>
                    </path>
                    <path>
                        <groupId>jakarta.persistence</groupId>
                        <artifactId>jakarta.persistence-api</artifactId>
                        <version>3.1.0</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Running mvn compile generates the Q-type classes under target/generated-sources/annotations. In IntelliJ IDEA, mark this directory as a Generated Sources Root.

Gradle Configuration

dependencies {
    implementation "com.querydsl:querydsl-jpa:5.1.0:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0"
}

sourceSets {
    main {
        java {
            srcDirs += ["build/generated/sources/annotationProcessor/java/main"]
        }
    }
}

Run ./gradlew compileJava to generate the Q-types. With sourceSets configured correctly, IntelliJ will automatically recognize them when importing the Gradle project. If it doesn’t, right-click the generated-sources directory and select “Mark Directory as → Generated Sources Root”.

Defining the JPAQueryFactory Bean

Register JPAQueryFactory, the entry point for QueryDSL:

@Configuration
@RequiredArgsConstructor
public class QueryDslConfig {

    private final EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

Spring injects a thread-safe proxy for EntityManager, so constructor injection is safe to use here.

Writing Dynamic Queries with BooleanBuilder

Here’s an example filtering users by name, status, and registration date:

@Repository
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    public UserQueryRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public List<User> searchUsers(String name, UserStatus status, LocalDate from) {
        QUser user = QUser.user;
        BooleanBuilder builder = new BooleanBuilder();

        if (name != null) builder.and(user.name.containsIgnoreCase(name));
        if (status != null) builder.and(user.status.eq(status));
        if (from != null) builder.and(user.createdAt.goe(from));

        return queryFactory
                .selectFrom(user)
                .where(builder)
                .orderBy(user.createdAt.desc())
                .fetch();
    }
}

null parameters are simply skipped, giving you dynamic queries automatically. The intent of the code is much clearer compared to Specification.

Splitting Conditions with BooleanExpression

Extracting conditions into static methods makes them easy to reuse:

private BooleanExpression nameContains(QUser user, String name) {
    return name != null ? user.name.containsIgnoreCase(name) : null;
}

private BooleanExpression statusEq(QUser user, UserStatus status) {
    return status != null ? user.status.eq(status) : null;
}

// Usage
queryFactory
    .selectFrom(user)
    .where(nameContains(user, name), statusEq(user, status))
    .fetch();

Passing null to where() causes that condition to be skipped. An added benefit is that each condition can be unit tested independently. When you want to share conditions across queries, or when you have more than five conditions, extracting them into BooleanExpression methods improves maintainability.

Pagination with Pageable

When combining with Spring Data JPA’s Pageable:

public Page<User> searchUsersPage(String name, Pageable pageable) {
    QUser user = QUser.user;
    BooleanExpression condition = nameContains(user, name);

    List<User> content = queryFactory
            .selectFrom(user)
            .where(condition)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    Long total = Objects.requireNonNullElse(
        queryFactory.select(user.count()).from(user).where(condition).fetchOne(),
        0L
    );

    return new PageImpl<>(content, pageable, total);
}

In QueryDSL 5.x, both fetchResults() and fetchCount() are deprecated. The current recommended pattern for count queries is select(user.count()).fetchOne(). Since fetchOne() can return null for the Long result, use Objects.requireNonNullElse for null-safe handling.

For broader pagination design, see the Pagination Implementation Guide.

Choosing Between QuerydslPredicateExecutor and Custom Repositories

If your repository already extends QuerydslPredicateExecutor<T>, you can keep using it for simple conditions. However, it has limits when it comes to customizing complex compound conditions or fine-grained pagination control. A practical approach is to migrate incrementally to the custom repository pattern described here once compound conditions become necessary.

Specification vs. QueryDSL Comparison

AspectSpecificationQueryDSL
SetupNone requiredAPT configuration needed
IDE completionLimitedFully functional
ReadabilityDegrades as conditions growHigh
Complex conditionsPainfulHandles well
TestabilitySomewhat difficultEasy to test conditions individually
Best suited forSimple searchesCompound conditions, reuse

The Specification pattern is perfectly sufficient for simple searches with three or four conditions or fewer. When conditions become complex or your team wants to improve reusability, QueryDSL is the better fit. You can also migrate incrementally while keeping existing Specification repositories in place.

Troubleshooting Setup Issues

Q-type classes not being generated is most often caused by a misconfigured annotationProcessorPaths in maven-compiler-plugin. Simply adding querydsl-apt to <dependency> is not enough — you must also add it to the plugin configuration.

<!-- ❌ Adding only to dependencies won't work -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <version>5.1.0</version>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

<!-- ✅ Also add to maven-compiler-plugin's annotationProcessorPaths -->
<annotationProcessorPaths>
    <path>
        <groupId>com.querydsl</groupId>
        <artifactId>querydsl-apt</artifactId>
        <version>5.1.0</version>
        <classifier>jakarta</classifier>
    </path>
    <path>
        <groupId>jakarta.persistence</groupId>
        <artifactId>jakarta.persistence-api</artifactId>
        <version>3.1.0</version>
    </path>
</annotationProcessorPaths>

If your IDE doesn’t recognize Q-types, check that the generated-sources directory is configured as a Sources Root.

Mixing javax.persistence and jakarta.persistence is another common issue. Spring Boot 3.x uses the jakarta package, so don’t forget to specify jakarta as the classifier in your dependencies.

Summary

With QueryDSL, even complex dynamic queries can be written in readable, maintainable Java code. The APT setup takes a little effort, but once it’s running, the development experience improves considerably. Start by trying it out in a simple repository class.

For performance optimization, see Spring Data JPA Performance Optimization.