QueryDSL in Spring Boot 2025

Master QueryDSL in Spring Boot 2025

QueryDSL in Spring Boot

QueryDSL makes writing database queries in Java safer and easier by replacing string-based queries with type-safe code. During compilation, it scans our entity classes and creates Q-classes that match our entity fields.

In this tutorial we’ll show learn how to use QueryDSL in Spring Boot to create clean, type-safe dynamic queries.

What is QueryDSL?

QueryDSL is a Java library that helps us write database queries in a type-safe way. Instead of writing raw SQL or JPQL strings, we can use Java code that gets checked at compile time. No more typos breaking our application!

Think of it like this: Instead of writing "SELECT * FROM users WHERE name = ?", we can write QUser.user.name.eq("John"). Much cleaner, right?

How It Works Behind the Scenes

When we create an entity class like User, QueryDSL generates a special “Q” class called QUser. This Q class contains all the fields from our entity but as type-safe query objects. So if our User has a name field, QUser will have QUser.user.name that we can use to build queries.

Furthermore, If we rename name to fullName in our entity, the Q class will update automatically. When we try to use the old field name in the query, the IDE will immediately show us the error – even before you compile!

Setting Up the Project

Let’s start with a Spring Boot project. Here’s what we need in the pom.xml:

  1. querydsl-jpa – This is the main QueryDSL library for JPA
  2. querydsl-apt – This is the annotation processor that generates the Q classes
  3. apt-maven-plugin – This Maven plugin runs the annotation processor during compilation

The apt-maven-plugin is crucial as it scans our @Entity classes and generates the corresponding Q classes. Without this plugin, we won’t have any Q classes to work with. The plugin is configured to output the generated classes to target/generated-sources/java, which Maven automatically includes in your classpath.

...    
    <properties>
        <java.version>17</java.version>
        <querydsl.version>5.0.0</querydsl.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- QueryDSL Dependencies -->
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>${querydsl.version}</version>
            <classifier>jakarta</classifier>
        </dependency>
        
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>${querydsl.version}</version>
            <classifier>jakarta</classifier>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            
            <!-- QueryDSL Maven Plugin -->
            <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>
        </plugins>
    </build>
...

The querydsl-jpa dependency provides the core functionality for working with JPA entities and creating database queries.

The querydsl-apt dependency is equally important but serves a different purpose. APT stands for Annotation Processing Tool, and this dependency contains the code that runs during compilation to generate our Q-classes.

The apt-maven-plugin runs during the Maven compile phase, specifically looking for classes annotated with JPA annotations like @Entity. When it finds such classes, it invokes the JPAAnnotationProcessor to generate corresponding Q-classes.

Creating User Entity

Let’s create a User entity that we’ll use for our examples:

@Entity
@Table(name = "users")
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    private Integer age;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "is_active")
    private Boolean isActive;

    public User() {}
    
    public User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
        this.createdAt = LocalDateTime.now();
        this.isActive = true;
    }

    // setters and getters
}

The @Entity annotation marks this class for JPA processing and QueryDSL code generation.

The @Column annotations provide additional metadata that QueryDSL incorporates into the generated code. For example, the @Column(name = "created_at") annotation tells QueryDSL that the createdAt field maps to a database column named “created_at“.

Basic Repository Setup

Next, let’s create a standard JPA repository:

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
   
}

By extending JpaRepository, we automatically get methods like findAll(), findById(), save(), and delete() without writing any implementation code.

QueryDSL Configuration

This configuration is essential because QueryDSL needs access to JPA’s EntityManager to execute queries against the database. Now, let’s set up QueryDSL configuration:

@Configuration
public class QueryDSLConfig {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

The @PersistenceContext annotation injects the JPA EntityManager, which serves as the bridge between your Java objects and the database.

The JPAQueryFactory is the heart of QueryDSL. It’s like a factory that creates queries for you. Think of it as the query builder tool – we give it instructions on what we want to find, and it creates the actual database queries.

The EntityManager is Spring’s JPA component that manages our database entities. QueryDSL needs this to understand your database structure and execute queries. By injecting the EntityManager into JPAQueryFactory, QueryDSL can work with our database just like Spring JPA does.

This configuration makes JPAQueryFactory available throughout our application. We can inject it into any service or repository where we need to write QueryDSL queries.

Creating the QueryDSL Service

Let’s create a service class that uses QueryDSL. Before we dive into the code, let’s understand the key concepts:

  • QUser.user – This is the generated query object for our User entity. It represents the User table in our database queries.
  • JPAQueryFactory – This is the main tool for building queries. It has methods like selectFrom(), select(), update(), delete().
  • BooleanBuilder – This is perfect for dynamic queries. We can add conditions one by one, and it combines them with AND logic.
@Service
public class UserQueryService {
    
    @Autowired
    private JPAQueryFactory queryFactory;
    
    private final QUser user = QUser.user;
    
    public List<User> findAllUsers() {
        return queryFactory
          .selectFrom(user)
          .fetch();
    }
}

The service class begins with dependency injection of the JPAQueryFactory, which provides access to QueryDSL’s query-building capabilities. The QUser.user static field represents the generated query root for the User entity. This field serves as the starting point for all queries involving User entities.

The findAllUsers()method demonstrates the basic QueryDSL query structure. The selectFrom() method indicates that we want to select all fields from the User table.

The fetch() method executes the query and returns a List of User entities. This translates to a simple “SELECT * FROM users” SQL query.

Single Entity Lookup

Finding a single entity by a specific criterion is a common database operation that QueryDSL handles elegantly with type safety:

public User findUserByName(String name) {
    return queryFactory
       .selectFrom(user)
       .where(user.name.eq(name))
       .fetchOne();
}

This method demonstrates QueryDSL’s where clause functionality and the fetchOne() method. The where() method accepts a Predicate that filters the results, and user.name.eq(name) creates a predicate that matches entities where the name field equals the provided parameter.

Range-Based Queries

Numeric range queries are common in business applications, and QueryDSL provides intuitive methods for handling them.

public List<User> findUsersByAgeRange(Integer minAge, Integer maxAge) {
    return queryFactory
      .selectFrom(user)
      .where(user.age.between(minAge, maxAge))
      .fetch();
}

The between() method creates a predicate that matches values within an inclusive range. This translates to SQL’s BETWEEN operator, which is both efficient and readable. QueryDSL’s type system ensures that we can only use between() with comparable types, preventing logical errors like trying to use between() with string and number values.

This method returns a List because range queries typically match multiple entities.

Boolean Field Queries

Boolean queries are straightforward but important for filtering active/inactive records and similar use cases.

public List<User> findActiveUsers() {
    return queryFactory
      .selectFrom(user)
      .where(user.isActive.eq(true))
      .fetch();
}

This query demonstrates working with Boolean fields in QueryDSL. The eq(true)method creates a predicate that matches entities where isActive equals true. QueryDSL also provides convenience methods like isTrue() and isFalse() that can make the code more readable for Boolean comparisons.

Complex Multi-Condition Queries

Sometimes, we require queries with multiple conditions combined with logical operators.

public List<User> findActiveUsersWithNameContaining(String namePattern) {
    return queryFactory
      .selectFrom(user)
      .where(user.isActive.eq(true)
      .and(user.name.containsIgnoreCase(namePattern)))
      .orderBy(user.createdAt.desc())
      .fetch();
}

The and() method combines multiple predicates with logical AND, ensuring that results must satisfy both conditions. The containsIgnoreCase() method performs a case-insensitive substring search, which is useful for user-facing search functionality.

In addition, the orderBy() method adds sorting to the query, and user.createdAt.desc() specifies descending order by creation date.

Dynamic Query Construction

Dynamic queries represent one of QueryDSL’s most powerful features, allowing us to build queries based on runtime conditions.

public List<User> findUsersWithDynamicFilters(String name, Integer minAge, Integer maxAge, Boolean isActive) {
    BooleanBuilder builder = new BooleanBuilder();
    
    if (name != null && !name.trim().isEmpty()) {
        builder.and(user.name.containsIgnoreCase(name));
    }
    
    if (minAge != null) {
        builder.and(user.age.goe(minAge));
    }
    
    if (maxAge != null) {
        builder.and(user.age.loe(maxAge));
    }
    
    if (isActive != null) {
        builder.and(user.isActive.eq(isActive));
    }
    
    return queryFactory
      .selectFrom(user)
      .where(builder)
      .orderBy(user.name.asc())
      .fetch();
}

The BooleanBuilder class starts as an empty condition container and allows us to add predicates conditionally based on our business logic. Each and() call adds another condition, but only if the corresponding parameter is provided.

The null checks prevent empty or meaningless conditions from being added to the query.

The goe() and loe() methods stand for “greater or equal” and “less or equal” respectively.

Pagination Support

Pagination is essential for applications that work with large datasets, and QueryDSL provides straightforward pagination capabilities:

public List<User> findUsersWithPagination(int offset, int limit) {
    return queryFactory
      .selectFrom(user)
      .orderBy(user.id.asc())
      .offset(offset)
      .limit(limit)
      .fetch();
}

The offset() and limit() methods directly translate to SQL’s LIMIT and OFFSET clauses. The offset parameter specifies how many records to skip, while the limit parameter specifies the maximum number of records to return.

The orderBy() clause is crucial for pagination consistency. Without explicit ordering, database engines may return records in different orders across pagination requests. Using the primary key (id) for ordering is often the most efficient choice because primary keys are typically indexed and unique.

Counting Records

Count queries provide essential metadata for pagination and user interface elements:

public long countActiveUsers() {
    return queryFactory
      .selectFrom(user)
      .where(user.isActive.eq(true))
      .fetchCount();
}

The fetchCount() method modifies the query to return a count of matching records instead of the records themselves.

Date-Based Queries

Temporal queries are common in business applications for filtering records by creation date, modification date, or other time-based criteria:

public List<User> findUsersCreatedAfter(LocalDateTime date) {
    return queryFactory
      .selectFrom(user)
      .where(user.createdAt.after(date))
      .orderBy(user.createdAt.desc())
      .fetch();
}

The after() method creates a temporal predicate that matches entities with createdAt values after the specified date.

Update Operations

QueryDSL supports bulk update operations that can modify multiple records in a single database operation:

public long deactivateUsersOlderThan(Integer age) {
    return queryFactory
      .update(user)
      .set(user.isActive, false)
      .where(user.age.gt(age))
      .execute();
}

The update() method starts a bulk update operation, and set() specifies which field to update with what value. The where() clause determines which records will be affected, and execute() performs the operation and returns the number of modified records.

The return value indicates how many records were actually modified, which is useful for confirming that the operation had the expected impact.

Delete Operations

Bulk delete operations follow a similar pattern to updates but permanently remove records from the database:

public long deleteInactiveUsers() {
    return queryFactory
      .delete(user)
      .where(user.isActive.eq(false))
      .execute();
}

The delete() method initiates a bulk delete operation, and the where() clause specifies which records to remove.

The return value provides confirmation of how many records were actually deleted.

Delete by List of IDs

public long deleteUsersByIds(List<Long> userIds) {
    return queryFactory
        .delete(user)
        .where(user.id.in(userIds))
        .execute();
}

The in() method accepts a collection of values and creates a predicate that matches any entity whose ID field contains one of those values. This translates to SQL’s IN operator, generating a query like “DELETE FROM users WHERE id IN (1, 2, 3)”.

Creating the Controller

Let’s create a REST controller to test our QueryDSL queries:

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserQueryService userQueryService;
    
    @GetMapping
    public List<User> getAllUsers() {
        return userQueryService.findAllUsers();
    }
    
    @GetMapping("/search")
    public List<User> searchUsers(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) Integer minAge,
            @RequestParam(required = false) Integer maxAge,
            @RequestParam(required = false) Boolean isActive) {
        
        return userQueryService.findUsersWithDynamicFilters(name, minAge, maxAge, isActive);
    }
    
    @GetMapping("/name/{name}")
    public User getUserByName(@PathVariable String name) {
        return userQueryService.findUserByName(name);
    }
    
    @GetMapping("/age-range")
    public List<User> getUsersByAgeRange(
            @RequestParam Integer minAge,
            @RequestParam Integer maxAge) {
        return userQueryService.findUsersByAgeRange(minAge, maxAge);
    }
    
    @GetMapping("/active")
    public List<User> getActiveUsers() {
        return userQueryService.findActiveUsers();
    }
    
    @GetMapping("/active/search")
    public List<User> searchActiveUsers(@RequestParam String name) {
        return userQueryService.findActiveUsersWithNameContaining(name);
    }
    
    @GetMapping("/paginated")
    public List<User> getPaginatedUsers(
            @RequestParam(defaultValue = "0") int offset,
            @RequestParam(defaultValue = "10") int limit) {
        return userQueryService.findUsersWithPagination(offset, limit);
    }
    
    @GetMapping("/count/active")
    public long countActiveUsers() {
        return userQueryService.countActiveUsers();
    }
    
    @PutMapping("/deactivate")
    public String deactivateOldUsers(@RequestParam Integer age) {
        long count = userQueryService.deactivateUsersOlderThan(age);
        return count + " users deactivated";
    }
    
    @DeleteMapping("/inactive")
    public String deleteInactiveUsers() {
        long count = userQueryService.deleteInactiveUsers();
        return count + " inactive users deleted";
    }
}

Application Properties

The application.properties file provides essential configuration for the database and development environment:

# Database Configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password

# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# H2 Console (for testing)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Server Configuration
server.port=8080

Build Process and Q-Class Generation

The Maven compilation process is where QueryDSL’s code generation happens, transforming our entities into queryable Q-classes.

When we run mvn clean compile, Maven executes several phases:

  1. The clean phase removes any previously generated files
  2. The compile phase processes your source code
  3. The APT plugin scans for @Entity annotations
  4. QueryDSL’s annotation processor generates Q-classes
  5. The generated classes are added to your classpath

The generated QUser class contains static fields and methods that correspond to our entity structure. We’ll find fields like public static final StringPath name and methods for query operations. These generated elements provide the type safety and IDE support that makes QueryDSL so powerful.

If we modify the entity classes, we must recompile to regenerate the Q-classes. This ensures that out queries always match our current entity structure and prevents runtime errors from schema mismatches.

Testing the API

Once the application is running, we can test these endpoints:

GET /api/users - Get all users

GET /api/users/search?name=John&minAge=20&maxAge=40&isActive=true - Dynamic search

GET /api/users/name/John%20Doe - Find user by name

GET /api/users/age-range?minAge=25&maxAge=35 - Users in age range

GET /api/users/active - Get active users only

GET /api/users/paginated?offset=0&limit=2 - Paginated results

Common QueryDSL Methods

Here are some useful QueryDSL methods we’ll use often:

String Operations – These work with text fields:

// String operations
user.name.eq("John")                    // equals - exact match
user.name.ne("John")                    // not equals - anything except "John"
user.name.like("J%")                    // like pattern - starts with "J"
user.name.contains("oh")                // contains - has "oh" anywhere in the name
user.name.startsWith("J")               // starts with - begins with "J"
user.name.endsWith("n")                 // ends with - ends with "n"
user.name.containsIgnoreCase("JOHN")    // case insensitive contains - finds "john", "JOHN", "John"

Numeric Operations – These work with numbers (Integer, Long, Double, etc.):

// Numeric operations
user.age.gt(25)                         // greater than - age > 25
user.age.goe(25)                        // greater or equal - age >= 25
user.age.lt(25)                         // less than - age < 25
user.age.loe(25)                        // less or equal - age <= 25
user.age.between(20, 30)                // between values - age BETWEEN 20 AND 30
user.age.in(20, 25, 30)                 // in list - age IN (20, 25, 30)

Boolean Operations – These work with true/false fields:

// Boolean operations
user.isActive.eq(true)                  // equals - is_active = true
user.isActive.isTrue()                  // is true - same as above, cleaner syntax
user.isActive.isFalse()                 // is false - is_active = false

Date Operations – These work with LocalDateTime, LocalDate, etc.:

// Date operations
user.createdAt.after(LocalDateTime.now())   // after date - created_at > now
user.createdAt.before(LocalDateTime.now())  // before date - created_at < now
user.createdAt.between(start, end)          // between dates - created_at BETWEEN start AND end

Combining Conditions – This is where QueryDSL really shines:

// Combining conditions
user.name.eq("John").and(user.age.gt(25))   // AND - both conditions must be true
user.name.eq("John").or(user.age.gt(25))    // OR - either condition can be true
user.name.eq("John").not()                  // NOT - negates the condition

Troubleshooting Common Issues

1. Q-classes not generated

Solution: Check Maven plugin configuration and run mvn clean compile. The APT plugin might not be running properly. Make sure your pom.xml has the correct plugin configuration and that your entities are properly annotated with @Entity.

2. ClassNotFoundException for Q-classes

Solution: Make sure the generated sources are in your classpath. Your IDE might not be recognizing the target/generated-sources/java directory. In IntelliJ, right-click this folder and select “Mark Directory as” > “Generated Sources Root”.

3. QueryDSL queries not working

Solution: Verify JPAQueryFactory bean is configured correctly. The EntityManager might not be injected properly, or you might be missing the @Configuration annotation on your config class. Check that Spring can find and create the JPAQueryFactory bean.

4. Queries returning unexpected results

Solution: Enable SQL logging to see what’s actually being generated. Add spring.jpa.show-sql=true to your application.properties and check the console output. Compare the generated SQL with what you expect.

5. “No property found” errors in QueryDSL

Solution: Regenerate Q-classes after entity changes. You probably changed your entity fields but didn’t regenerate the Q-classes. Run mvn clean compile to sync them up.

Conclusion

In this article, we’ve completed implementing QueryDSL in Spring Boot. QueryDSL is a powerful tool that makes database queries much safer and easier to write. With type-safe queries, we can catch errors early and write more maintainable code. The dynamic query capabilities make it perfect for search functionality where users can filter by multiple criteria.

Leave a Comment