Master QueryDSL in Spring Boot 2025
Contents
- 1 QueryDSL in Spring Boot
- 2 Setting Up the Project
- 3 Creating User Entity
- 4 QueryDSL Configuration
- 5 Creating the QueryDSL Service
- 5.1 Single Entity Lookup
- 5.2 Range-Based Queries
- 5.3 Boolean Field Queries
- 5.4 Complex Multi-Condition Queries
- 5.5 Dynamic Query Construction
- 5.6 Pagination Support
- 5.7 Counting Records
- 5.8 Update Operations
- 5.9 Delete Operations
- 5.10 Delete by List of IDs
- 5.11 Creating the Controller
- 5.12 Application Properties
- 6 Build Process and Q-Class Generation
- 7 Testing the API
- 8 Common QueryDSL Methods
- 9 Troubleshooting Common Issues
- 10 Conclusion
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
:
- querydsl-jpa – This is the main QueryDSL library for JPA
- querydsl-apt – This is the annotation processor that generates the Q classes
- 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:
- The clean phase removes any previously generated files
- The compile phase processes your source code
- The APT plugin scans for @Entity annotations
- QueryDSL’s annotation processor generates Q-classes
- 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.