Why JPQL Projection Beats Manual Mapping and MapStruct in Spring Boot 3
A Spring Boot 3.4.2 tutorial compares four entity‑to‑DTO conversion strategies—manual mapping, MapStruct, JPQL DTO projection, and JPQL record projection—using JMH benchmarks on an H2 in‑memory database, revealing that JPQL projection is roughly three times faster than the other approaches.
1. Introduction
Converting JPA entities to Data Transfer Objects (DTOs) is a common practice to decouple the persistence layer from the API layer, control exposed fields, and improve security. Different conversion techniques, however, have markedly different performance characteristics.
2. Benchmark Setup
Environment : Spring Boot 3.4.2, Spring Data JPA, H2 in‑memory database, JMH for micro‑benchmarking.
2.1 Dependencies
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-spring-boot-starter</artifactId>
<version>1.5.0</version>
</dependency>2.2 Application Configuration
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password: ""
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
spring:
sql:
init:
mode: embedded
schema-locations:
- optional:classpath*:scripts/schema.sql
data-locations:
- optional:classpath*:scripts/data.sql2.3 Entity, DTO, and Record
@AutoMapper(target = BookDTO.class)
@Entity
@Table(name = "t_book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String author;
private LocalDate releaseDate;
private Long pages;
private String lang;
}
public class BookDTO {
private Long id;
private String name;
private String author;
private LocalDate releaseDate;
private Long pages;
private String lang;
}
public record BookRecord(Long id, String name, String author, LocalDate releaseDate, Long pages, String lang) {}2.4 Repository with JPQL Projections
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("""
SELECT new com.pack.entity_dto_performance.BookDTO(
e.id, e.name, e.author, e.releaseDate, e.pages, e.lang)
FROM Book e
""")
List<BookDTO> queryBookToDTO();
@Query("""
SELECT new com.pack.entity_dto_performance.BookRecord(
e.id, e.name, e.author, e.releaseDate, e.pages, e.lang)
FROM Book e
""")
List<BookRecord> queryBookToRecord();
}2.5 Service Layer Offering Four Mapping Strategies
@Service
public class BookService {
private final BookRepository bookRepository;
private final Converter converter;
public BookService(BookRepository bookRepository, Converter converter) {
this.bookRepository = bookRepository;
this.converter = converter;
}
public List<BookDTO> getBooksManual() {
return bookRepository.findAll().stream()
.map(b -> new BookDTO(b.getId(), b.getName(), b.getAuthor(),
b.getReleaseDate(), b.getPages(), b.getLang()))
.toList();
}
public List<BookDTO> getBooksByMapStruct() {
return converter.convert(bookRepository.findAll(), BookDTO.class);
}
public List<BookDTO> getBooksByJpql() {
return bookRepository.queryBookToDTO();
}
public List<BookRecord> getBooksByJpqlRecord() {
return bookRepository.queryBookToRecord();
}
}2.6 JMH Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(value = 1, jvmArgs = {"-Xms2g", "-Xmx2g"})
@Warmup(iterations = 2, time = 3)
@Measurement(iterations = 3, time = 3)
public class BookServiceBenchmark {
private ConfigurableApplicationContext context;
private BookService bookService;
@Setup
public void setup() {
this.context = SpringApplication.run(SpringBootJpaApplication.class,
"--spring.main.web-application-type=none");
this.bookService = context.getBean(BookService.class);
}
@TearDown
public void tearDown() {
if (context != null) {
context.close();
}
}
@Benchmark
public void testManualMapping(Blackhole bh) {
bh.consume(bookService.getBooksManual());
}
@Benchmark
public void testMapStruct(Blackhole bh) {
bh.consume(bookService.getBooksByMapStruct());
}
@Benchmark
public void testJpqlDto(Blackhole bh) {
bh.consume(bookService.getBooksByJpql());
}
@Benchmark
public void testJpqlRecord(Blackhole bh) {
bh.consume(bookService.getBooksByJpqlRecord());
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(BookServiceBenchmark.class.getSimpleName())
.forks(1)
.build();
new Runner(opts).run();
}
}3. Performance Test Results
The benchmark measured average execution time for each method. The result image below shows the measured latencies.
JPQL constructor expressions that directly create BookDTO or BookRecord achieve the best performance, around 6.2 µs per call, which is roughly three times faster than manual mapping or MapStruct (≈ 17 µs). The speedup comes from projecting only the required columns at the database level, avoiding full entity materialization and a second mapping step, thus reducing memory usage and CPU overhead—especially beneficial for read‑only queries.
4. Conclusion
When the goal is to retrieve read‑only data, using JPQL projection (either DTO or Java record) is the most efficient approach in Spring Boot applications. It simplifies code, eliminates unnecessary entity loading, and delivers significant performance gains over manual mapping or MapStruct.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
