DTO Projections
When you only need a few fields, loading entire entities wastes memory and SQL bandwidth. Projections let a Spring Data JPA repository return a slimmer shape — an interface, a record, or a dynamically chosen type — and, in the best case, push that narrowing all the way down to the SELECT clause.
This page covers the four flavors that matter in practice: closed interface projections, open interface projections, DTO/record projections via JPQL constructor expressions, and dynamic projections using generics.
Closed interface projections
A closed projection is an interface whose getters match entity property names exactly. Spring Data implements the interface as a proxy and — because every accessor maps to a known column — Hibernate selects only those columns.
public interface ProductView {
String getName();
BigDecimal getPrice();
}
public interface ProductRepository extends JpaRepository<Product, Long> {
List<ProductView> findByCategory(String category);
}
Calling findByCategory("books") does not issue a select *:
select p1_0.name, p1_0.price
from product p1_0
where p1_0.category = ?
Tip: Closed projections are the cheapest read path in Spring Data. The narrower
SELECTreduces row width, avoids loading large@Lobcolumns, and sidesteps lazy-association proxies entirely.
Nested closed projections also work — a getter returning another projection interface follows the association and still narrows columns.
Open interface projections
An open projection uses @Value with a SpEL expression to compute a value from the backing entity, exposed via the target variable.
public interface CustomerView {
String getEmail();
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
The catch: because SpEL can reference arbitrary properties of target, Spring Data cannot reason about which columns are needed, so it loads the full entity and then runs the expression in memory.
select c1_0.id, c1_0.email, c1_0.first_name, c1_0.last_name, c1_0.created_at
from customer c1_0
Warning: Open projections silently lose the column-narrowing optimization. If you only added
@Valuefor a trivial concatenation, prefer a closed projection plus formatting in the caller, or a record DTO.
Record (DTO) projections via constructor expressions
A constructor expression in JPQL builds immutable DTOs directly in the query using new <fully-qualified-class>(...). Java records are an ideal fit.
package com.app.dto;
import java.math.BigDecimal;
public record ProductDto(String name, BigDecimal price) {}
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("select new com.app.dto.ProductDto(p.name, p.price) from Product p where p.category = :category")
List<ProductDto> findDtosByCategory(String category);
}
select p1_0.name, p1_0.price
from product p1_0
where p1_0.category = ?
Note: The constructor argument order and types must match a record/class constructor exactly, and you must use the fully qualified class name (no imports in JPQL). Like closed projections, this narrows the
SELECT.
Dynamic projections with generics
Sometimes one query method should return different shapes depending on the caller. A dynamic projection accepts a Class<T> and lets Spring Data pick the implementation strategy at runtime.
public interface ProductRepository extends JpaRepository<Product, Long> {
<T> List<T> findByCategory(String category, Class<T> type);
}
List<ProductView> views = repository.findByCategory("books", ProductView.class);
List<ProductDto> dtos = repository.findByCategory("books", ProductDto.class);
Product full = repository.findByCategory("books", Product.class).get(0);
Passing a projection interface or DTO class narrows columns; passing the entity class returns full entities. This keeps a single, well-tested query method while serving multiple presentation needs.
Comparison
| Aspect | Closed interface | Open interface | Record/DTO (JPQL) | Dynamic (generic) |
|---|---|---|---|---|
| Narrows columns? | Yes | No | Yes | Depends on type passed |
| Loads full entity? | No | Yes | No | Depends on type passed |
| Immutable result? | Yes (read-only proxy) | Yes (read-only proxy) | Yes (record) | Matches chosen type |
| Custom/computed fields? | No | Yes (SpEL) | Yes (constructor logic) | Matches chosen type |
| When to use | Fast reads of a few fields | Light derived fields, full entity acceptable | API responses, immutable DTOs | One method, many shapes |
Tip: For most read endpoints reach for a closed interface or a record DTO. Avoid open projections unless you truly need SpEL and don’t mind loading the entity.
Pitfalls
- Open projection performance: as shown above,
@Valueforfeits the narrowSELECT; don’t use it for hot paths. - Constructor mismatch: a wrong argument count or type in the JPQL
newexpression fails at startup with a query-validation error — verify it matches the record canonical constructor. - Pagination still works: projection methods can return
Page<ProductView>orSlice<ProductDto>; the count query runs against the entity, not the projection. - Native queries + interface projections need column aliases that match getter names (camelCase mapped to the underlying column).