Hire Me
← All Writing Spring Boot

Spring Data JPA Projections — Interface and DTO Projections Without Boilerplate

How to use Spring Data JPA projections to retrieve partial data — interface projections, class-based DTO projections, and dynamic projections — without manual mapping or loading unused columns.

A common anti-pattern in Spring Data JPA is loading the full entity for operations that only need a few fields. If a UI table shows order ID, market, and price, loading the full Order entity with all its associations and columns wastes memory, increases query time, and loads data that the caller will never use. Spring Data projections solve this: define the shape you need, and Spring Data generates the SQL to match.

The problem with loading full entities

// Loads all 20 columns of Order, plus joins to Market and Runner entities
List<Order> orders = orderRepository.findByMarketId(marketId);

// Caller only uses three fields
return orders.stream()
    .map(o -> new OrderSummary(o.getId(), o.getPrice(), o.getSize()))
    .collect(toList());

The full Order entity loads columns that are discarded immediately. Worse, if the entity has @OneToMany associations, they may trigger additional queries (N+1).

Interface projections

Define a projection as an interface with getter methods matching the entity fields:

public interface OrderSummaryProjection {
    String getId();
    double getPrice();
    double getSize();
    String getSide();
}

Use it as the return type in the repository:

public interface OrderRepository extends JpaRepository<Order, String> {
    List<OrderSummaryProjection> findByMarketId(String marketId);
}

Spring Data generates:

SELECT o.id, o.price, o.size, o.side FROM orders o WHERE o.market_id = ?

Only the four requested columns are fetched. No joins to unused associations.

Nested interface projections

Project across associations:

public interface OrderWithMarketProjection {
    String getId();
    double getPrice();
    MarketProjection getMarket();

    interface MarketProjection {
        String getName();
        Instant getStartTime();
    }
}

Spring Data generates the appropriate join. The nested interface is resolved as a lazy proxy — the market association is fetched in a single joined query, not as an N+1.

SpEL expressions in projections

Compute derived values directly in the projection:

public interface OrderSummaryProjection {
    String getId();
    double getPrice();
    double getSize();
    String getSide();

    @Value("#{target.price * target.size}")
    double getStakeValue();

    @Value("#{target.side == 'LAY' ? target.size * (target.price - 1) : target.size}")
    double getLiability();
}

target refers to the underlying entity bean. SpEL expressions are evaluated in-memory after the query — use them for simple derived values, not for large-scale computation.

Class-based (DTO) projections

Interface projections use JDK proxies, which adds overhead per accessor call. For high-volume reads, class-based projections with a constructor are faster:

public record OrderSummary(String id, double price, double size, String side) {}
@Query("SELECT new com.example.OrderSummary(o.id, o.price, o.size, o.side) " +
       "FROM Order o WHERE o.marketId = :marketId")
List<OrderSummary> findSummariesByMarket(@Param("marketId") String marketId);

The fully-qualified class name in the new expression is verbose but explicit. Spring Data calls the constructor directly — no proxy, no reflection overhead at read time.

Alternatively, use @Query with Spring Data’s built-in DTO projection support (Spring Boot 3.2+):

List<OrderSummary> findByMarketId(String marketId);

If OrderSummary is a record or class with a constructor matching the entity field names, Spring Data maps it automatically without a @Query.

Dynamic projections

Choose the projection type at runtime:

public interface OrderRepository extends JpaRepository<Order, String> {
    <T> List<T> findByMarketId(String marketId, Class<T> type);
}

// Usage
List<OrderSummary>    summaries = repo.findByMarketId(id, OrderSummary.class);
List<OrderDetailView> details   = repo.findByMarketId(id, OrderDetailView.class);
List<Order>           full      = repo.findByMarketId(id, Order.class);

The same repository method serves multiple projections. Spring Data detects the type and adjusts the generated SQL.

When to use each

Type Best for
Interface projection Quick read, simple fields, few callers
Nested interface projection Associated data without N+1
DTO/record projection High-volume reads, computed fields, API responses
Dynamic projection Single method serving multiple use cases

What projections don’t fix

Projections reduce column fetching but do not change the query structure. If you need to aggregate across thousands of rows (sum, count, group by), a @Query with explicit SQL is still better than loading and aggregating in Java. Projections are an optimisation for column selection, not query logic.

Projections also work with Spring Data MongoDB, R2DBC, and other Spring Data modules — the interface/class pattern is consistent across backends.

If you’re optimising Spring Data repository performance and want a review, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.