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 projectionClass (DTO) projection
BoilerplateMinimalConstructor required
Nested relationsSupported nativelyManual JOIN + constructor
SpEL expressionsSupportedNo (compute in code)
Type safetyProxy (dynamic)Concrete class
JSON serializationWorks (proxy)Works (POJO)
PerformanceSameSame

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 Pageable and Page<T> seamlessly.

Next: Article 21 covers Specifications and the Criteria API — how to build dynamic, type-safe queries at runtime.