JPQL, Criteria API & Queries

Projections & DTO Queries

18 min Lesson 9 of 13

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.

The key insight: Hibernate only manages what it loads. A DTO is just a plain Java object — it is never added to the persistence context, it is never dirty-checked, and it is never proxied. That is exactly what you want for read operations.

Scalar Projections with Object Arrays

The simplest projection selects named fields and returns Object[] arrays:

// repository or service method List<Object[]> rows = em.createQuery( "SELECT p.id, p.name, p.price FROM Product p WHERE p.active = true", Object[].class) .getResultList(); for (Object[] row : rows) { Long id = (Long) row[0]; String name = (String) row[1]; BigDecimal price = (BigDecimal) row[2]; }

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:

// DTO — a plain record or class, NO @Entity annotation public record ProductSummary(Long id, String name, BigDecimal price) {}
List<ProductSummary> summaries = em.createQuery( "SELECT NEW com.example.dto.ProductSummary(p.id, p.name, p.price) " + "FROM Product p WHERE p.active = true ORDER BY p.name", ProductSummary.class) .getResultList();

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.

Use Java records as DTOs. A record declares the canonical constructor automatically, which is exactly what JPQL's 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.

// Projection interface — no implementation needed public interface ProductSummaryView { Long getId(); String getName(); BigDecimal getPrice(); }
// Repository public interface ProductRepository extends JpaRepository<Product, Long> { // Spring Data derives the SQL from the return type automatically List<ProductSummaryView> findByActiveTrue(); // Or combine with @Query for full control @Query("SELECT p.id AS id, p.name AS name, p.price AS price " + "FROM Product p WHERE p.category = :cat") List<ProductSummaryView> findSummariesByCategory(@Param("cat") String category); }
The N+1 trap with interface projections: If the interface getter references a lazily-loaded association (e.g. 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:

import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface OrderRepository extends JpaRepository<Order, Long> { @Query("SELECT NEW com.example.dto.OrderLineDto(" + " o.id, o.createdAt, p.name, oi.quantity, oi.unitPrice) " + "FROM Order o " + "JOIN o.items oi " + "JOIN oi.product p " + "WHERE o.customer.id = :customerId " + "ORDER BY o.createdAt DESC") List<OrderLineDto> findOrderLinesByCustomer(@Param("customerId") Long customerId); }
public record OrderLineDto( Long orderId, LocalDateTime createdAt, String productName, int quantity, BigDecimal unitPrice ) { // Derived field — no database column required public BigDecimal lineTotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } }

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():

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<ProductSummary> cq = cb.createQuery(ProductSummary.class); Root<Product> p = cq.from(Product.class); cq.select(cb.construct( ProductSummary.class, p.get("id"), p.get("name"), p.get("price") )); cq.where(cb.isTrue(p.get("active"))); List<ProductSummary> results = em.createQuery(cq).getResultList();

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.

Always measure. Use Hibernate statistics (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.