Projections e DTOs — não vazar a entidade

TL;DR

Para listagens read-only, traga só os campos que precisa: use projections (interface, DTO via SELECT new, ou dynamic). A entidade JPA fica no domínio — quem atravessa a borda é o DTO. Carregar a entidade inteira só para ler dois campos é desperdício de memória e um convite para N+1 acidental.


O que é

Projection é qualquer mecanismo que restringe quais colunas o Spring Data retorna em vez de materializar a entidade completa.

O Spring Data JPA oferece três sabores:

SaborMecanismoProxy?
Interface projectionSpring cria um proxy no runtimeSim
Class-based / DTO projectionJPQL SELECT new … ou mapeamento diretoNão
Dynamic projectionTipo escolhido em tempo de chamada via Class<T>Depende do tipo passado

Por que importa

Uma entidade JPA carregada no persistence context tem custo: o Hibernate precisa gerenciar o estado dela para dirty-checking, precisa rastrear relações lazy e pode disparar queries extras ao navegar por associações. Para uma resposta de listagem — que nunca vai ser modificada e deve virar JSON logo em seguida — isso é trabalho desnecessário.

Além disso, expor diretamente a entidade no JSON (via @RestController) acopla o contrato da API ao modelo de banco. Se a coluna total mudar de tipo ou for renomeada, a API quebra. O DTO é o firewall entre as duas camadas.


Como funciona

Interface projection — o proxy do Spring (closed vs open)

Declara-se uma interface com getters cujos nomes correspondem às propriedades da entidade. O Spring gera um proxy em tempo de execução que intercepta as chamadas e as redireciona ao resultado da query.

Closed projection — todos os getters batem em propriedades reais. O Spring consegue reescrever o SQL para selecionar só essas colunas.

public interface OrderSummary {
    Long getId();
    BigDecimal getTotal();
    String getCustomerName();   // campo calculado de Customer.name
}
 
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<OrderSummary> findByStatus(OrderStatus status);
}

Open projection — um getter usa @Value com SpEL, computando um valor a partir de target. O Spring não consegue otimizar: carrega a entidade inteira para então calcular a expressão.

public interface OrderSummary {
    @Value("#{target.id + '-' + target.status}")
    String getLabel();   // open: Spring precisa do target inteiro
}

Prefira closed projections ou default methods

Em vez de @Value com SpEL, use um default method que combine os getters já declarados. Assim o Spring ainda otimiza a query e você tem a lógica de composição na interface.

public interface OrderSummary {
    Long getId();
    String getStatus();
 
    default String getLabel() {
        return getId() + "-" + getStatus();
    }
}

Class-based / DTO projection — record via SELECT new

Sem proxy. O resultado da query é passado diretamente para o construtor do DTO. Java Records são a forma mais enxuta.

public record OrderDto(Long id, BigDecimal total) {}

Com query derivada, o Spring Data tenta inferir o construtor:

List<OrderDto> findByStatus(OrderStatus status);

Com @Query explícito, usa-se a sintaxe JPQL de constructor expression:

@Query("SELECT new com.app.order.OrderDto(o.id, o.total) FROM Order o WHERE o.status = :status")
List<OrderDto> findSummaryByStatus(@Param("status") OrderStatus status);

Pacote qualificado obrigatório

O SELECT new precisa do nome totalmente qualificado da classe (com.app.order.OrderDto), não apenas OrderDto.

O Spring Data JPA também é capaz de reescrever automaticamente queries que selecionam a entidade raiz para a forma de constructor expression quando o tipo de retorno é um DTO compatível:

// Spring reescreve isso para SELECT new OrderDto(o.id, o.total) FROM Order o …
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<OrderDto> findByStatusRewritten(@Param("status") OrderStatus status);

Dynamic projection — <T> List<T> findBy...(Class<T>)

Quando o mesmo método de repositório precisa devolver tipos diferentes dependendo do contexto, passa-se o tipo como parâmetro.

public interface OrderRepository extends JpaRepository<Order, Long> {
    <T> List<T> findByStatus(OrderStatus status, Class<T> type);
}

Uso:

// Entidade completa (para modificação)
List<Order> orders = repository.findByStatus(OPEN, Order.class);
 
// Projeção leve (para listagem)
List<OrderSummary> summaries = repository.findByStatus(OPEN, OrderSummary.class);

Quando usar projection (listagem read-only) vs entidade (modificar)

SituaçãoO que usarMotivo
Listagem paginada, somente leituraInterface ou DTO projectionMenos dados, sem dirty-checking
Endpoint de detalhes que vai modificarEntidade completaPrecisa do ciclo de vida JPA
Resposta de API RESTDTO (class-based)Contrato explícito, não vaza schema
Consulta em lote, relatórioDTO via SELECT newSem overhead de proxy
Método de repositório reutilizávelDynamic projectionFlexibilidade em tempo de chamada

Na prática

Cenário: expor um resumo de pedidos para uma listagem paginada.

Entidade

import jakarta.persistence.*;
import java.math.BigDecimal;
 
@Entity
@Table(name = "orders")
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    private BigDecimal total;
 
    private String status;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;
 
    // getters e setters omitidos
}

Interface projection (closed)

public interface OrderSummary {
    Long getId();
    BigDecimal getTotal();
    String getStatus();
}

DTO record via SELECT new

public record OrderDto(Long id, BigDecimal total, String status) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    // Interface projection derivada
    List<OrderSummary> findByStatus(String status);
 
    // DTO via JPQL explícito
    @Query("""
        SELECT new com.app.order.OrderDto(o.id, o.total, o.status)
        FROM Order o
        WHERE o.status = :status
        """)
    List<OrderDto> findDtoByStatus(@Param("status") String status);
 
    // Dynamic projection
    <T> List<T> findAllByStatus(String status, Class<T> type);
}

Uso do dynamic projection no serviço

// Contexto de leitura: projeção leve
List<OrderSummary> summaries =
    orderRepository.findAllByStatus("OPEN", OrderSummary.class);
 
// Contexto de modificação: entidade completa
List<Order> orders =
    orderRepository.findAllByStatus("OPEN", Order.class);

Armadilhas

(1) Carregar a entidade inteira para ler 3 campos

findAll() retorna List<Order> com todos os campos e todas as relações inicializadas (ou agendadas para lazy load). Para uma listagem com 1.000 pedidos, isso pode significar gigabytes de dado desnecessário. Use uma closed projection ou DTO projection.

(2) Projection que toca relação lazy — N+1 sutil

Se a interface projection declara getCustomer() retornando outra interface de projeção, o Spring vai disparar uma query extra por pedido para carregar o Customer. É a mesma armadilha do N+1, só que mais difícil de enxergar porque não há customer.getName() explícito no código — o proxy faz isso silenciosamente. Combine com @EntityGraph se precisar de dados do relacionamento.

(3) Expor a entidade diretamente no JSON

Retornar Order em um @RestController acopla o contrato da API ao modelo de persistência. Renomear uma coluna, adicionar um campo @Transient ou alterar um relacionamento quebra a API sem aviso em tempo de compilação. O contrato da API é o DTO — veja Serialização JSON (Galho 9).


Em entrevista

Frase pronta (inglês)

“In Spring Data JPA, projections let you retrieve only the columns you actually need instead of loading the full entity. For read-only use cases like paginated lists, I prefer closed interface projections or class-based DTO projections using records and SELECT new in JPQL — both avoid loading the full entity graph and prevent dirty-checking overhead. I keep the JPA entity strictly in the domain layer and always expose a DTO at the API boundary to decouple the persistence model from the API contract.”

Vocabulário

PortuguêsInglês
projeçãoprojection
projeção de interfaceinterface projection
projeção fechadaclosed projection
projeção abertaopen projection
projeção baseada em classeclass-based projection
objeto de transferência de dadosdata transfer object (DTO)
somente leituraread-only
proxyproxy
expressão de construtor (JPQL)constructor expression
projeção dinâmicadynamic projection

Veja também


Referências