Projections & DTO Queries
Projections & DTO Queries
When you run a JPQL query that returns a managed entity, Hibernate loads every mapped column, registers the object in the first-level cache, and tracks it for dirty-checking. For read-only reporting and API responses that need only a handful of fields, that overhead is pure waste. Projections let you select exactly the columns you need and materialize them directly into a lightweight DTO — skipping entity overhead entirely.
Why Avoid Full Entity Loads for Read-Only Data
Imagine a product catalogue endpoint that returns an id, name, and price for each product. Loading a full Product entity also fetches the description blob, image URLs, audit timestamps, and any eagerly-loaded associations. Each of those becomes a heap object. Under load, with hundreds of concurrent requests, that difference is measurable: more garbage collection pressure, more L1 cache misses, and slower query execution because the database sends more bytes over the wire.
Scalar Projections with Object Arrays
The simplest projection selects named fields and returns Object[] arrays:
This works but is fragile: the mapping from index to field is implicit and breaks silently if the SELECT clause is reordered. Use it only for quick prototyping.
Constructor Expressions — The Standard Approach
JPQL supports a NEW expression that invokes a Java constructor directly inside the query:
Hibernate reads the fully-qualified class name, resolves the matching constructor, and calls it for each row. The result is a strongly-typed list with no casting.
NEW expression calls. Records are immutable, concise, and work perfectly for read models.
Spring Data JPA Interface Projections
Spring Data JPA adds a higher-level projection mechanism: you define an interface with getter methods matching the fields you want, and Spring generates a proxy at runtime.
getCategoryName() that delegates to product.getCategory().getName()), Spring will fire a separate query per row. Always check the generated SQL with spring.jpa.show-sql=true when using nested projections.
Class-Based (DTO) Projections with @Query
For the most explicit and refactor-safe approach, combine @Query with a constructor expression:
Notice that lineTotal() is computed in Java from fields already loaded — no extra query needed. This is a common pattern: fetch the raw numbers, compute derived values in the DTO.
Criteria API DTO Projections with CriteriaBuilder.construct()
When the query is built dynamically (Lesson 6), you can still project into a DTO using CriteriaBuilder.construct():
This is the type-safe equivalent of NEW in JPQL, usable with dynamically-built predicates.
Choosing the Right Projection Strategy
- Full entity load — when you need to modify data and commit changes. The persistence context's dirty-checking saves you explicit UPDATE statements.
- Constructor expression / record DTO — the default for read-only endpoints. Strongly typed, zero overhead, portable across JPA providers.
- Interface projection — convenient when using Spring Data derived queries and the projection is shallow (no association traversal). Less code but more magic.
- Object[] scalar — avoid in production code; useful for ad-hoc debugging.
Performance Comparison
Consider a query returning 1,000 orders with five columns each. Loading full Order entities might also trigger lazy-load proxies for the Customer association, resulting in 1,001 SQL statements (the classic N+1). A DTO projection with a JOIN in the JPQL query issues exactly one statement and transfers only the five requested columns — typically 3–10× faster for reporting workloads.
hibernate.generate_statistics=true) or P6Spy to count actual SQL statements in your integration tests before and after switching to projections.
Summary
Projections are the primary tool for keeping read paths lean in a JPA application. The constructor expression (NEW com.example.dto.SomeDto(...)) is the portable, explicit choice and pairs perfectly with Java records. Spring Data interface projections reduce boilerplate for simple cases but require care around association traversal. Criteria API projections via cb.construct() extend the same pattern to dynamic queries. In all cases the goal is the same: load only the data you need, skip the persistence context overhead, and return a plain, immutable value object to your callers.