Projections and DTOs: Fetching Only What You Need
The Over-Fetching Problem
By default, findAll() loads every column for every entity. A Product entity with 20 fields loads all 20 columns — even when a dropdown menu only needs id and name. This wastes bandwidth, memory, and query time.
Projections let you define what shape the result should take, and Spring Data JPA generates a query that fetches only those columns.
Interface Projections
The simplest projection: declare an interface with getter methods matching entity field names.
public interface ProductNameView {
Long getId();
String getName();
BigDecimal getPrice();
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<ProductNameView> findByActiveTrue();
List<ProductNameView> findByCategoryId(Long categoryId);
}
Spring generates a proxy implementing ProductNameView and a SQL query that selects only id, name, and price. No Product entity is instantiated.
-- Generated SQL
SELECT p.id, p.name, p.price FROM products p WHERE p.active = 1
Nested Interface Projections
Projections can include related entity data:
public interface ProductWithCategoryView {
Long getId();
String getName();
BigDecimal getPrice();
CategoryView getCategory(); // nested projection
interface CategoryView {
Long getId();
String getName();
}
}
List<ProductWithCategoryView> findByActiveTrue();
Spring performs a JOIN and populates the nested projection. The generated SQL selects only the projected columns from both tables.
Computed Properties with SpEL
Interface projections support @Value with Spring Expression Language for computed fields:
public interface ProductSummaryView {
Long getId();
String getName();
BigDecimal getPrice();
int getStockQuantity();
@Value("#{target.price * target.stockQuantity}")
BigDecimal getTotalStockValue();
@Value("#{target.name + ' (' + target.sku + ')'}")
String getDisplayName();
}
target refers to the underlying entity/object. Computed properties load the full entity in memory (Hibernate fetches all columns), because the expression needs the entity to compute them. Use them sparingly.
Class-Based (DTO) Projections
Interface projections use proxy objects. For a concrete Java class, use a DTO with a constructor that Spring calls via JPQL’s new expression:
public class ProductDto {
private final Long id;
private final String name;
private final BigDecimal price;
private final String categoryName;
public ProductDto(Long id, String name, BigDecimal price, String categoryName) {
this.id = id;
this.name = name;
this.price = price;
this.categoryName = categoryName;
}
// getters...
}
@Query("""
SELECT new com.example.dto.ProductDto(
p.id, p.name, p.price, p.category.name
)
FROM Product p
JOIN p.category c
WHERE p.active = true
""")
List<ProductDto> findActiveProductDtos();
The fully qualified class name is required in the JPQL new expression. The constructor parameter order must match the SELECT expression order.
Record-Based DTO (Java 16+)
Java records are ideal for DTO projections — immutable, concise, and no boilerplate:
public record ProductDto(Long id, String name, BigDecimal price, String categoryName) {}
@Query("""
SELECT new com.example.dto.ProductDto(p.id, p.name, p.price, p.category.name)
FROM Product p JOIN p.category c WHERE p.active = true
""")
List<ProductDto> findActiveProductDtos();
Interface vs Class Projections
| Interface projection | Class (DTO) projection | |
|---|---|---|
| Boilerplate | Minimal | Constructor required |
| Nested relations | Supported natively | Manual JOIN + constructor |
| SpEL expressions | Supported | No (compute in code) |
| Type safety | Proxy (dynamic) | Concrete class |
| JSON serialization | Works (proxy) | Works (POJO) |
| Performance | Same | Same |
Use interface projections for simple field subsets. Use DTO projections when you need concrete types, records, or complex cross-entity aggregations.
Dynamic Projections
One repository method can return different projections based on the caller’s need:
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
<T> List<T> findByCategoryId(Long categoryId, Class<T> type);
}
// Caller decides the projection type
List<ProductNameView> names = repo.findByCategoryId(1L, ProductNameView.class);
List<ProductDto> dtos = repo.findByCategoryId(1L, ProductDto.class);
List<Product> full = repo.findByCategoryId(1L, Product.class); // full entity
Spring generates a different query for each Class<T> — fetching only the columns the projection needs.
Native Query Projections
Interface projections also work with native queries:
public interface ProductStockView {
Long getId();
String getName();
int getStockQuantity();
BigDecimal getPrice();
}
@Query(
value = "SELECT id, name, stock_quantity, price FROM products WHERE category_id = :catId",
nativeQuery = true
)
List<ProductStockView> findStockByCategoryNative(@Param("catId") Long catId);
Column aliases in the SQL must match the getter names in the interface (case-insensitive, underscores ignored):
// Interface getter: getStockQuantity()
// SQL alias: stock_quantity ← Spring maps this via camelCase conversion
// If needed, use an explicit alias:
@Query(
value = "SELECT id, name, stock_quantity AS stockQuantity FROM products",
nativeQuery = true
)
List<ProductStockView> findAll();
Projections with Pagination
Projections compose cleanly with Pageable:
Page<ProductNameView> findByActiveTrue(Pageable pageable);
Page<ProductNameView> page = productRepository.findByActiveTrue(
PageRequest.of(0, 20, Sort.by("name"))
);
The count query is also generated automatically and selects only the projected columns.
Open vs Closed Projections
Closed projection: all getters map to entity fields directly. Spring can optimize the SQL to select only those columns.
public interface ProductNameView {
Long getId(); // maps to p.id
String getName(); // maps to p.name
}
// → SELECT p.id, p.name FROM products p
Open projection: at least one getter uses @Value/SpEL, meaning it needs the full entity.
public interface ProductDisplayView {
Long getId();
@Value("#{target.name + ' - $' + target.price}")
String getLabel(); // needs both name and price
}
// → SELECT p.* FROM products p (all columns loaded)
Closed projections are generally preferred — they directly reduce the data fetched.
Practical Example: Product Catalogue API
// Projections
public interface ProductListView {
Long getId();
String getName();
BigDecimal getPrice();
String getSku();
CategoryNameView getCategory();
interface CategoryNameView {
String getName();
String getSlug();
}
}
public record ProductDetailDto(
Long id,
String name,
String description,
BigDecimal price,
int stockQuantity,
String sku,
String categoryName
) {}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Catalogue list — minimal data
Page<ProductListView> findByActiveTrueOrderByNameAsc(Pageable pageable);
// Detail page — full DTO
@Query("""
SELECT new com.example.dto.ProductDetailDto(
p.id, p.name, p.description, p.price, p.stockQuantity, p.sku, p.category.name
)
FROM Product p JOIN p.category c
WHERE p.id = :id AND p.active = true
""")
Optional<ProductDetailDto> findDetailById(@Param("id") Long id);
// Autocomplete — id + name only
List<ProductNameView> findByNameContainingIgnoreCase(String keyword);
}
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
@GetMapping
public Page<ProductListView> list(
@PageableDefault(size = 20, sort = "name") Pageable pageable
) {
return productRepository.findByActiveTrueOrderByNameAsc(pageable);
}
@GetMapping("/{id}")
public ProductDetailDto detail(@PathVariable Long id) {
return productRepository.findDetailById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
@GetMapping("/search")
public List<ProductNameView> autocomplete(@RequestParam String q) {
return productRepository.findByNameContainingIgnoreCase(q);
}
}
Each endpoint fetches exactly the data it needs — no over-fetching.
Summary
- Interface projections: declare getter methods matching entity fields; Spring generates an optimized SQL selecting only those columns. Best for simple subsets.
- Class/record projections: use JPQL
new com.example.Dto(...)for concrete types. Best when you need records, immutable objects, or cross-entity aggregations. - Dynamic projections: one method, multiple possible projection types — caller passes
Class<T>. - Native projections: interface projections work with
nativeQuery = true; column aliases must match getter names. - Open projections (
@Value/SpEL) force loading the full entity — keep projection interfaces closed when possible. - Projections compose with
PageableandPage<T>seamlessly.
Next: Article 21 covers Specifications and the Criteria API — how to build dynamic, type-safe queries at runtime.