skip to content

When would you choose a class-based DTO projection over an interface projection, and what are the key tradeoffs and pitfalls at scale?

level: principalimportance: should knowfreq 40%

answer

  1. Closed = narrow SELECT; open/entity = full load
  2. DTO record: serialize, cache, cross boundaries
  3. Interface: open SpEL + easy nesting
  4. Watch N+1 across association getters
  5. Native SQL → @SqlResultSetMapping, not select new

basics

~20 s

Choose a class/record DTO when you want a concrete, immutable, detached object you can pass around and serialize freely. Choose an interface projection when you want lightweight read-only views or need open SpEL-computed values. Both closed forms cut columns.

solid answer

~50 s

Both class DTOs and interface projections are read models that avoid returning managed entities. Prefer a **class-based DTO (record)** when the result is a real value object you construct, serialize, cache, or pass across layers — it is concrete, immutable, easy to unit-test, and unambiguous. Prefer an **interface projection** for quick, declarative views, especially when you need **open** projections (`@Value` SpEL to combine/compute fields) or nested associations, which class DTOs handle poorly. Tradeoffs: closed interface projections and DTOs both restrict the SELECT list; open interface projections and full entities do not. DTOs give you a stable API contract decoupled from schema, at the cost of a bit more boilerplate. At scale watch for: N+1 from projections traversing associations, aggregation limits, native-query mapping needing @SqlResultSetMapping, and inconsistent column optimization when mixing @Query with projections. For complex, composable read models, consider QueryDSL/Blaze-Persistence over pure Spring Data projections.

code

java · 11 lines
java
// Class DTO with a join, avoiding N+1 by selecting associated columns directly
public record OrderSummary(Long id, String customerName, java.math.BigDecimal total) {}

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("""
        select new com.example.dto.OrderSummary(o.id, c.name, o.total)
        from Order o join o.customer c
        where o.status = :status
        """)
    List<OrderSummary> summaries(@Param("status") OrderStatus status);
}

go deeper

for a junior

Know DTO vs interface both avoid returning the raw entity.

for a middle

Explain closed-vs-open column optimization and when each style fits.

for a senior

Reason about N+1, nested-projection limits, native mapping, and constructor ambiguity.

for a principal

Frame projections as CQRS read models decoupling API from schema; set org-level guidance and know when to escalate to QueryDSL/Blaze-Persistence.

**The three read shapes.** From one entity you can return: (1) the **entity** — managed, all columns, mutable; (2) an **interface projection** — a proxy exposing getters; (3) a **class-based DTO** — a constructed concrete object (ideally a record). Interface projections split further into **closed** (only accessors that map 1:1 to properties) and **open** (at least one `@Value("#{target.x + target.y}")` SpEL accessor). **Column optimization.** This is the crux of performance discussions: - **Closed interface projection** and **class DTO** → Spring Data can generate SQL that selects **only** the needed columns (narrow SELECT), reducing I/O. - **Open interface projection** → the SpEL needs the whole backing object, so the **entire entity is loaded**; no narrowing. - **Entity** → all mapped columns loaded. So 'projection' does not automatically mean 'cheaper' — only *closed* projections narrow the query. **Choosing DTO vs interface.** - Use a **class DTO/record** when: you need a genuine value object to serialize to JSON, cache, put on an event/queue, pass across module boundaries (fits Spring Modulith's public-API ethos), or unit-test with plain constructors. Records give immutability and equality for free. - Use an **interface projection** when: you want minimal boilerplate for an internal view, need **open** computed fields, or want convenient nested projections following associations (interfaces support nested projections more naturally than class DTOs). **Pitfalls at scale.** 1. **N+1 queries.** A projection that exposes an association getter can trigger per-row lazy loads. Projections do not magically join; design the query (fetch joins in @Query, or select the associated columns directly into the DTO) to avoid it. 2. **Nested class DTOs.** Support is limited/version-dependent; deeply nested DTO graphs may not map. Interface projections handle nesting better, or use an explicit constructor expression with joins. 3. **Native queries.** `select new` is JPQL-only. For native SQL you need `@SqlResultSetMapping` + `@ConstructorResult`, or an interface projection matched by column **alias** names. 4. **@Query + projection column optimization.** When you hand-write the JPQL selecting the full entity and pass a closed projection, the SELECT may not be narrowed automatically — the optimization is most reliable with derived queries. Verify with SQL logging if it matters. 5. **Constructor ambiguity.** Multiple constructors on a class DTO require `@PersistenceCreator`; otherwise instantiation is ambiguous. 6. **Parameter names.** Name-based matching (derived DTO) relies on parameter names; records preserve them, but plain classes need `-parameters` compilation. 7. **Over-projecting.** Too many bespoke DTOs per query becomes a maintenance burden; dynamic projections (`Class<T>`) mitigate by serving multiple shapes from one method. **Architectural framing.** DTO projections are effectively a lightweight **read model** (CQRS-flavored): the write side uses entities, the read side uses purpose-built DTOs. This decouples the API contract from schema evolution and is a natural fit for module-per-bounded-context designs. When read models get complex (dynamic filters, many joins, computed aggregates), pure Spring Data projections strain — reach for **QueryDSL**, **JPA Criteria**, or **Blaze-Persistence Entity Views**, which are built for composable projections. **Decision heuristic.** Need to serialize/cache/pass around or cross a boundary → class DTO/record. Need computed/open fields or easy nesting for an internal view → interface projection. Need the same lookup at multiple fidelities → dynamic projection. Always confirm you're on a *closed* form if column-narrowing performance is the goal.

  • Does using any projection guarantee fewer columns are fetched?
    No. Only closed projections (closed interface or class DTO) let Spring Data narrow the SELECT. Open interface projections and returning the entity load all mapped columns.
  • How do class-based DTOs and interface projections differ on nested/associated data?
    Interface projections support nested projections more naturally by declaring getters that return other projection interfaces. Class DTOs have limited, version-dependent nesting support; you often model the join explicitly in a constructor expression instead.
  • What is the native-SQL equivalent of a JPQL constructor expression?
    @SqlResultSetMapping with a @ConstructorResult (mapping columns to a DTO constructor), or an interface projection whose getter names match the SQL column aliases. `select new` is not valid in native queries.
  • When would you move beyond Spring Data projections entirely?
    When read models need dynamic filters, many joins, or composable computed views — QueryDSL, JPA Criteria, or Blaze-Persistence Entity Views handle that far better than static projection types.

saying these in an interview costs you the question

  • Claiming all projections reduce fetched columns
  • Using select new in native queries
  • Ignoring N+1 when a projection exposes association getters
  • Thinking interface and class projections are interchangeable for nesting
  • Forgetting @PersistenceCreator with multiple DTO constructors

context