Definition
- Interface-based projection – define a Java interface with getter methods matching entity field names. Spring Data generates a proxy at runtime that maps only those columns from the SQL result.
- DTO/class-based projection – use a JPQL constructor expression
SELECT new Dto(e.field1, e.field2)or a Java record as the return type. Direct object creation with no proxy and no persistence context entry. - Dynamic projection –
lets the caller choose the projection class at runtime.List findByStatus(String status, Class type) - Performance ranking – DTO is fastest (fewest columns, no proxy, no persistence context). Interface is second (few columns but proxy creation cost). Full entity is slowest (all columns, snapshots, dirty checking).
Loading the full Account entity is like the National Bank of India clerk photocopying Rajesh's entire 20-page customer file every time. Interface projection is like writing only his name and balance on a pre-printed slip. DTO projection is like printing a receipt with exactly the three fields the mobile app needs. Dynamic projection is like letting Rajesh choose which form he wants at the counter.
Understanding the Concepts
What are JPA projections?
JPA projections fetch only the columns you need instead of loading the entire entity. This reduces data transfer from the database, memory usage in the application, and eliminates persistence context overhead for read-only queries.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "trades")
public class Trade {
@Id
private String tradeId;
private String symbol;
private BigDecimal amount;
private String status;
private LocalDate settlementDate;
private String notes; // large TEXT field
private String auditHistory; // large TEXT field
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_id")
private Account account;
// 8+ columns. The list API only needs tradeId, symbol, amount.
}
How does interface-based projection work?
Define an interface with getter methods for the fields you want. Spring Data generates a proxy at runtime that maps only those columns.
package com.bank.dto;
import java.math.BigDecimal;
public interface TradeIdAndSymbol {
String getTradeId();
String getSymbol();
BigDecimal getAmount();
}
package com.bank.repository;
import com.bank.dto.TradeIdAndSymbol;
import com.bank.model.Trade;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TradeRepository extends JpaRepository<Trade, String> {
List<TradeIdAndSymbol> findByStatus(String status);
// Generates: SELECT t.trade_id, t.symbol, t.amount
// FROM trades t WHERE t.status = ?
// NOT: SELECT * FROM trades (all 8+ columns)
}
SQL generated:
SELECT t.trade_id, t.symbol, t.amount FROM trades t WHERE t.status = 'SETTLED'
-- Only 3 columns fetched instead of 8+
How does DTO/class-based projection work?
DTO projection uses a JPQL constructor expression or a Java record as the return type. This is the most performant approach – no proxy, no entity management, direct object construction.
package com.bank.dto;
import java.math.BigDecimal;
public record TradeOverview(String tradeId, String symbol, BigDecimal amount) {}
package com.bank.repository;
import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface TradeRepository extends JpaRepository<Trade, String> {
@Query("SELECT new com.bank.dto.TradeOverview(t.tradeId, t.symbol, t.amount) "
+ "FROM Trade t WHERE t.status = :status")
List<TradeOverview> findOverviewByStatus(@Param("status") String status);
// Result: List<TradeOverview> – plain records, no proxies,
// no persistence context entry
}
How does dynamic projection work?
Dynamic projection lets the caller choose the projection class at runtime using a Class parameter.
package com.bank.repository;
import com.bank.model.Trade;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TradeRepository extends JpaRepository<Trade, String> {
<T> List<T> findByAccountId(String accountId, Class<T> type);
}
package com.bank.service;
import com.bank.dto.TradeIdAndSymbol;
import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class TradeDynamicService {
private final TradeRepository tradeRepo;
public TradeDynamicService(TradeRepository tradeRepo) {
this.tradeRepo = tradeRepo;
}
@Transactional(readOnly = true)
public void dynamicProjectionDemo() {
// Same method, different projections!
List<TradeIdAndSymbol> idsOnly =
tradeRepo.findByAccountId("SAV2001", TradeIdAndSymbol.class);
List<TradeOverview> overviews =
tradeRepo.findByAccountId("SAV2001", TradeOverview.class);
List<Trade> fullEntities =
tradeRepo.findByAccountId("SAV2001", Trade.class);
}
}
How is interface projection different from DTO projection?
| Feature | Interface Projection | DTO Projection |
|---------|---------------------|----------------|
| Definition | Interface with getters | Record or class with constructor |
| Implementation | Spring-generated proxy | Direct instantiation |
| SpEL @Value | Supported | Not supported |
| Persistence context | Not managed | Not managed |
| Nested projections | Supported (nested interfaces) | Not in constructor expression |
| Performance | Proxy overhead | No overhead (fastest) |
| Use case | Quick prototyping, computed fields | Production API responses |
package com.bank.dto;
import java.math.BigDecimal;
import org.springframework.beans.factory.annotation.Value;
// Interface projection with SpEL computed field
public interface AccountSummary {
String getAccountId();
String getHolderName();
BigDecimal getBalance();
@Value("#{target.holderName + ' (' + target.accountId + ')'}")
String getDisplayName(); // computed: "Rajesh Kumar (SAV2001)"
}
// SpEL @Value only works with interface projections, not DTO records
What is the performance difference?
For a table with 20 columns and 10,000 rows where the API needs only 3 columns, projections dramatically reduce resource usage.
package com.bank.service;
import com.bank.dto.TradeIdAndSymbol;
import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PerformanceComparisonService {
private final TradeRepository tradeRepo;
public PerformanceComparisonService(TradeRepository tradeRepo) {
this.tradeRepo = tradeRepo;
}
@Transactional(readOnly = true)
public void compareProjections() {
// FULL ENTITY: SELECT * (all 20 columns) + persistence context
List<Trade> full = tradeRepo.findByStatus("SETTLED");
// Memory: ~10MB (data + snapshots)
// INTERFACE: SELECT trade_id, symbol, amount (3 columns) + proxy
List<TradeIdAndSymbol> iface =
tradeRepo.findByStatus("SETTLED");
// Memory: ~500KB (proxy objects)
// DTO: SELECT trade_id, symbol, amount (3 columns) + plain record
List<TradeOverview> dto =
tradeRepo.findOverviewByStatus("SETTLED");
// Memory: ~300KB (plain records, no proxy)
}
}
Performance comparison (10,000 rows, 20-column table):
Full entity:
SQL: SELECT * FROM trades (20 columns x 10K = 200K fields)
Memory: ~5MB data + ~5MB snapshots = 10MB
Dirty checking: 20,000 comparisons at flush
Interface projection:
SQL: SELECT trade_id, symbol, amount (3 columns x 10K = 30K fields)
Memory: ~500KB (proxy objects)
Dirty checking: none
DTO projection:
SQL: SELECT trade_id, symbol, amount (3 columns x 10K = 30K fields)
Memory: ~300KB (plain records)
Dirty checking: none
Winner: 85% less data, 97% less memory
What are common mistakes with projections?
The most common mistake is using full entities for read-only API responses. The second mistake is forgetting the fully qualified class name in constructor expressions.
package com.bank.repository;
import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TradeRepository extends JpaRepository<Trade, String> {
// WRONG: no package name in constructor expression
// @Query("SELECT new TradeOverview(t.tradeId, t.symbol, t.amount) FROM Trade t")
// Throws: org.hibernate.hql.internal.ast.QuerySyntaxException
// CORRECT: fully qualified class name
@Query("SELECT new com.bank.dto.TradeOverview(t.tradeId, t.symbol, t.amount) "
+ "FROM Trade t WHERE t.status = 'SETTLED'")
List<TradeOverview> findSettledOverviews();
}
When should you use each projection type?
Use DTO projections for production API responses – they are the fastest and produce the lightest payloads. Use interface projections for quick prototyping or when you need SpEL computed fields. Use full entities only when you need to modify the data.
package com.bank.service;
import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TradeDecisionService {
private final TradeRepository tradeRepo;
public TradeDecisionService(TradeRepository tradeRepo) {
this.tradeRepo = tradeRepo;
}
// READ: use DTO projection (fastest, lightest)
@Transactional(readOnly = true)
public java.util.List<TradeOverview> listTrades(String status) {
return tradeRepo.findOverviewByStatus(status);
}
// WRITE: use full entity (dirty checking needed)
@Transactional
public void settleTrade(String tradeId) {
Trade trade = tradeRepo.findById(tradeId).orElseThrow();
trade.setStatus("SETTLED");
// Dirty checking detects the change and generates UPDATE
}
}
Visual Overview
Full Entity: ┌───────────────────────────────────┐ │ SELECT * FROM trades (20 columns) │ │ → Entity in persistence context │ │ → Snapshot stored for dirty check │ │ → 10MB memory for 10K rows │ └───────────────────────────────────┘ Interface Projection: ┌───────────────────────────────────┐ │ SELECT id, symbol, amount (3 cols) │ │ → Spring-generated proxy object │ │ → NOT in persistence context │ │ → 500KB memory for 10K rows │ └───────────────────────────────────┘ DTO Projection (fastest): ┌───────────────────────────────────┐ │ SELECT id, symbol, amount (3 cols) │ │ → Plain record/object (no proxy) │ │ → NOT in persistence context │ │ → 300KB memory for 10K rows │ └───────────────────────────────────┘
Key Takeaway
JPA projections fetch only the columns your API needs, dramatically reducing data transfer, memory, and CPU. Interface projections declare getter methods in an interface – Spring generates a proxy. They uniquely support SpEL @Value for computed fields like getDisplayName(). DTO projections use a JPQL constructor expression or a record – no proxy, direct object creation, and the fastest approach.
At National Bank of India, the Trade entity has 12 columns including large TEXT fields for notes and audit history. The trade listing API needs only tradeId, symbol, and amount. Rajesh uses DTO projection TradeOverview(tradeId, symbol, amount) which fetches 3 columns instead of 12 – 85% less data transferred. For 10,000 trades: full entity loads 120,000 field values plus 10,000 snapshots. DTO loads 30,000 field values with zero snapshots.
Always prefer DTO projections for read-only API endpoints. Use interface projections for quick prototyping or when you need SpEL computed fields. Use full entities only when you need to modify the data, because dirty checking requires the entity in the persistence context.
Remember one sentence: DTO projections fetch only what you need, skip the persistence context entirely, and are the fastest way to read data through JPA.
---