Back to Projections: Interface, DTO, Dynamic
Q3Question 3 of 4

JPA projections performance comparison?

Key Concepts
  • Projections fetch only the columns you need instead of loading full managed entities, reducing data transfer and heap usage.
  • Interface projections declare getter methods and Spring builds a proxy; closed projections narrow the SQL, open projections load the full entity.
  • DTO/class projections use a JPQL constructor expression or a Java record and never enter the persistence context, making them the fastest option.
  • Dynamic projections accept a Class<T> parameter so one repository method returns different shapes at runtime.
  • Full entities are the slowest because every row enters the persistence context with a dirty-check snapshot.
  • Nested interface projections across associations can silently fire N+1 queries, so keep projections flat or use JOIN FETCH.

Definition

Projections fetch only the columns you need as lightweight read models, far cheaper than loading full entities.
  • Projection – a query result that returns only selected columns instead of the full entity, reducing data transfer, memory, and CPU.
  • Interface projection – a plain Java interface with getter methods matching entity properties; Spring Data builds a proxy at runtime backed by only those columns.
  • Closed projection – an interface projection where every getter maps directly to a property, allowing Hibernate to narrow the SQL SELECT to just those columns.
  • Open projection – an interface projection using @Value SpEL expressions that can reference any property, forcing Hibernate to load the full entity first.
  • DTO/class projection – a constructor expression in JPQL (SELECT new ...) or a Java record that produces an immutable, unmanaged object with no dirty-check overhead.
  • Dynamic projection – a generic repository method with a Class parameter so the caller picks the shape (interface, DTO, or full entity) at runtime.

Fetching a full entity is like National Bank of India pulling Rajesh's entire file, every page, photo, and history, just to read his balance. A projection is like asking only for a small index card with the two fields you actually need: name and balance. Interface projections are a card the clerk fills using a stencil (a proxy). DTO projections are a card you designed yourself with an exact slot for each field. Dynamic projections let you ask for different card layouts from the same request depending on what the caller wants.

Understanding the Concepts

What is a JPA projection?

A projection is a query result that returns only selected columns rather than the full entity. Instead of loading every column of an Account entity into a managed object with dirty-check tracking, a projection loads only the columns a screen or API actually needs. This reduces the data transferred from the database, the memory allocated on the heap, and the CPU spent on entity management.

JAVA
package com.bank.projection;

import java.math.BigDecimal;

// Interface projection: only two columns fetched
public interface AccountBalanceView {
    String getAccountId();
    BigDecimal getBalance();
}
JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import com.bank.model.Account;
import com.bank.projection.AccountBalanceView;
import java.util.List;

public interface AccountRepository extends JpaRepository<Account, String> {
    List<AccountBalanceView> findByBranchCode(String branchCode);
    // SQL: SELECT a.account_id, a.balance FROM accounts a WHERE a.branch_code = ?
}
TEXT
Expected result:
- Only account_id and balance columns selected.
- No entity enters the persistence context.
- No dirty-check snapshot allocated.

Why do projections exist?

Projections exist because loading full entities for read-only screens is wasteful. An Account entity might have 15 columns, but a dashboard listing only needs the id and balance. Loading all 15 columns means more data transferred over the network, more memory allocated for entity instances and their dirty-check snapshots, and more CPU spent at flush time comparing current state to snapshots. Projections eliminate all of this overhead for pure reads.

JAVA
package com.bank.dto;

import java.math.BigDecimal;

// DTO record: immutable, no persistence context overhead
public record AccountSummary(String accountId, BigDecimal balance) {}
TEXT
Performance comparison for 10,000 accounts:
- Full entity:      SELECT * -> 15 columns, managed, snapshots -> ~120ms, ~80MB
- Interface proxy:  SELECT id, balance -> 2 columns, proxy -> ~50ms, ~20MB
- DTO record:       SELECT id, balance -> 2 columns, plain object -> ~45ms, ~15MB

How does an interface projection work internally?

When you declare a repository method that returns an interface type, Spring Data inspects the interface's getter methods. For a closed projection (getters that map directly to entity properties), Spring Data rewrites the SQL SELECT to include only those columns. At runtime, Spring Data creates a JDK dynamic proxy that implements the interface, and each getter returns the corresponding column value. The proxy is not a managed entity and does not enter the persistence context.

For an open projection that uses @Value with SpEL expressions, Spring Data cannot determine which columns are needed at compile time, so it loads the full entity first and then computes the SpEL expression against it. This loses the column-narrowing benefit.

JAVA
package com.bank.projection;

import java.math.BigDecimal;
import org.springframework.beans.factory.annotation.Value;

// CLOSED projection: only these two columns are fetched
public interface AccountBalanceView {
    String getAccountId();
    BigDecimal getBalance();
}

// OPEN projection: loads the FULL entity, then computes
public interface AccountDisplayView {
    @Value("#{target.holderName + ' - ' + target.accountId}")
    String getDisplayLabel();
}
TEXT
Closed projection SQL: SELECT a.account_id, a.balance FROM accounts a WHERE ...
Open projection SQL:   SELECT a.* FROM accounts a WHERE ...  (full entity loaded!)

How does a DTO/class projection work?

A DTO projection uses a JPQL constructor expression to create plain Java objects directly from the query results. The constructor parameter order must match the SELECT clause exactly, and you must use the fully qualified class name in classic JPQL. Since Spring Data 3, you can also return a Java record and Spring Data maps column-by-column automatically.

JAVA
package com.bank.dto;

import java.math.BigDecimal;

public record AccountSummary(String accountId, BigDecimal balance) {}
JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import com.bank.model.Account;
import com.bank.dto.AccountSummary;
import java.util.List;

public interface AccountRepository extends JpaRepository<Account, String> {

    @Query("SELECT new com.bank.dto.AccountSummary(a.accountId, a.balance) " +
           "FROM Account a WHERE a.branchCode = :branch")
    List<AccountSummary> findSummaryByBranch(String branch);
}
TEXT
Expected SQL: SELECT a.account_id, a.balance FROM accounts a WHERE a.branch_code = ?
Result: List of AccountSummary records, immutable, no proxy, no persistence context.

How does a dynamic projection work?

A dynamic projection uses a generic type parameter and a Class argument on the repository method. The caller decides at runtime which shape to return: an interface projection, a DTO record, or the full entity.

JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import com.bank.model.Account;
import java.util.List;

public interface AccountRepository extends JpaRepository<Account, String> {
    <T> List<T> findByBranchCode(String branchCode, Class<T> type);
}
JAVA
package com.bank.service;

import com.bank.repository.AccountRepository;
import com.bank.projection.AccountBalanceView;
import com.bank.dto.AccountSummary;
import com.bank.model.Account;
import java.util.List;

public class AccountService {

    private final AccountRepository accountRepository;

    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    public void demonstrate() {
        // Lightweight interface proxy
        List<AccountBalanceView> views = accountRepository.findByBranchCode("MUM001", AccountBalanceView.class);

        // Full managed entity
        List<Account> entities = accountRepository.findByBranchCode("MUM001", Account.class);
    }
}
TEXT
With AccountBalanceView.class: SELECT a.account_id, a.balance FROM accounts a WHERE ...
With Account.class:            SELECT a.* FROM accounts a WHERE ...  (full entity)

How do the three projection types compare?

| Aspect | Interface (closed) | DTO/Class | Dynamic |
|---|---|---|---|
| SQL columns | Only declared getters | Only constructor params | Depends on Class |
| Return type | Proxy | Plain object / record | Varies |
| Persistence context | No | No | Only if full entity |
| Dirty checking | No | No | Only if full entity |
| Nested associations | Possible but N+1 risk | Explicit in constructor | Varies |
| Immutability | Interface (read-only) | Record (immutable) | Varies |
| Performance rank | 2nd (small proxy cost) | 1st (no proxy) | Depends on shape |

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.projection.AccountBalanceView;
import com.bank.dto.AccountSummary;
import com.bank.model.Account;
import java.util.List;

@Service
public class DashboardService {

    private final AccountRepository accountRepository;

    public DashboardService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(readOnly = true)
    public List<AccountSummary> getDtoProjection(String branch) {
        // FASTEST: no proxy, no persistence context
        return accountRepository.findSummaryByBranch(branch);
    }

    @Transactional(readOnly = true)
    public List<AccountBalanceView> getInterfaceProjection(String branch) {
        // FAST: proxy, no persistence context
        return accountRepository.findByBranchCode(branch);
    }

    @Transactional(readOnly = true)
    public List<Account> getFullEntities(String branch) {
        // SLOWEST: full entity, persistence context, dirty-check snapshots
        return accountRepository.findAllByBranchCode(branch);
    }
}

What are common mistakes with projections?

The most common mistake is using an open interface projection and assuming it narrows the SQL. Open projections with @Value SpEL expressions load the full entity, losing the performance benefit. A second mistake is using nested interface projections for associations, which triggers an extra query per row (N+1).

JAVA
// WRONG: open projection loads the full entity
package com.bank.projection;

import org.springframework.beans.factory.annotation.Value;

public interface AccountDisplayView {
    @Value("#{target.holderName + ' (Acc: ' + target.accountId + ')'}")
    String getLabel();   // FULL entity loaded, then SpEL computed
}
JAVA
// CORRECT: closed projection with only the getters you need
package com.bank.projection;

public interface AccountDisplayView {
    String getHolderName();
    String getAccountId();
    // Compute the label in Java after fetching, not via SpEL
}

When should you use each projection type?

Use closed interface projections for simple read screens where you need a few columns and want minimal code. Use DTO/record projections for API responses where you want an explicit, immutable contract and the best performance. Use dynamic projections when one endpoint must serve multiple shapes, such as a summary view and a detail view from the same query. Use full entities only when you need to modify and save the data.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.model.Account;
import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        // FULL ENTITY needed here because we modify and save
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        // Dirty checking detects the balance changes and generates UPDATEs
    }
}

What should a senior engineer know?

A senior engineer knows that projections pair perfectly with @Transactional(readOnly=true) because they avoid persistence-context overhead entirely. They know that DTO projections are the natural fit for aggregations like grouped sums and counts. They know that native queries can map to interface projections by column alias. They also know that nested projections for associations can reintroduce N+1 unless the query uses a JOIN FETCH, so they design the query and the projection together.

JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import com.bank.model.Account;
import com.bank.dto.BranchTotal;
import java.util.List;

public interface AccountRepository extends JpaRepository<Account, String> {

    // DTO projection with aggregation
    @Query("SELECT new com.bank.dto.BranchTotal(a.branchCode, SUM(a.balance)) " +
           "FROM Account a GROUP BY a.branchCode")
    List<BranchTotal> findBranchTotals();
}
JAVA
package com.bank.dto;

import java.math.BigDecimal;

public record BranchTotal(String branchCode, BigDecimal totalBalance) {}
TEXT
Expected SQL: SELECT a.branch_code, SUM(a.balance) FROM accounts a GROUP BY a.branch_code
Result: List of BranchTotal records with aggregated balances per branch.

Visual Overview

TEXT
  Projection Type Comparison
  ============================================

  Full Entity                     Interface Projection
  +-----------+                   +------------------+
  | SELECT *  |                   | SELECT id, bal   |
  |     |     |                   |       |          |
  |     v     |                   |       v          |
  | Entity    |                   | JDK Proxy        |
  | + snapshot|                   | (no snapshot)    |
  | (managed) |                   | (not managed)    |
  +-----------+                   +------------------+

  DTO Projection                  Dynamic Projection
  +-----------+                   +------------------+
  | SELECT    |                   | <T> findBy(...)  |
  | new DTO(  |                   |       |          |
  |  id, bal) |                   |  Class<T> type   |
  |     |     |                   |       |          |
  |     v     |                   |   DTO? Entity?   |
  | Record    |                   |   Interface?     |
  | (plain)   |                   +------------------+
  +-----------+

  Performance Ranking (best to worst)
  ============================================
  1. DTO/Record projection   -- fewest columns, no proxy, no PC
  2. Closed interface proxy  -- fewest columns, small proxy cost
  3. Open interface proxy    -- loads FULL entity, then computes
  4. Full managed entity     -- all columns, snapshot, dirty check

Key Takeaway

Projections let Rajesh return only the columns a read actually needs instead of loading full managed entities, which is a major win on the read-heavy dashboards at National Bank of India. There are three kinds: an interface projection is a plain Java interface with getters, and Spring builds a proxy backed by only those columns; a DTO projection uses a constructor expression or a record and produces an immutable object that never enters the persistence context; and a dynamic projection takes a Class parameter so one repository method can return different shapes at runtime.

On performance, loading full entities is the heaviest option because it selects every column, instantiates a managed entity, and registers it for dirty checking. Closed interface projections and DTO projections are the lightest because the SQL selects only the projected columns and the results are never managed. The one trap is the open interface projection using SpEL expressions, which loads the whole entity first and then computes, throwing away the benefit entirely.

The senior position Pankaj takes is that DTO projections are the default for read endpoints because they give the best performance, the strongest type safety, and an explicit API contract. Interface projections are a quick win for internal screens. Dynamic projections serve endpoints that must return both a summary and a full detail from one query. Full entities are reserved for methods that need to modify and save data.

Remember one sentence: DTO projections fetch the fewest columns and skip the persistence context entirely, making them the fastest read path in JPA.

---

Share
Use ← → arrow keys to navigate