Spring8 min de leitura

Projeção com Records no Spring Data JPA

Projeção com Records no Spring Data JPA: pare de carregar a entidade inteira só para listar

Terça de manhã, o suporte manda um print. A tela de pedidos leva 7 segundos para mostrar 50 linhas.

Você abre o código e encontra isto:

List<Pedido> findByStatus(StatusPedido status);

Aí você olha a tela de novo. Número, valor e data. Três colunas.

Se o seu código está assim hoje, tem explicação. É o caminho que quase todo tutorial de Spring Data ensina, e ele funciona bem enquanto a tabela é pequena. O incômodo aparece quando a base cresce e ninguém volta para revisitar aquela consulta.

Este artigo mostra o que acontece por baixo quando você pede a entidade, o que o readOnly = true já resolve, o que sobra depois dele, e como projetar direto para um Record.


O que o Hibernate faz quando você pede a entidade

Quando uma query devolve List<Pedido>, o Hibernate faz três coisas.

Primeiro ele monta o objeto a partir do ResultSet, campo por campo. Esse processo tem nome: hidratação.

Depois ele coloca esse objeto no contexto de persistência, que é o mapa de tudo que a sessão está gerenciando naquela transação.

E, em modo de leitura e escrita, ele guarda também uma cópia dos valores originais. Essa cópia é o snapshot. No final da transação, no flush, o Hibernate compara o objeto atual com o snapshot para descobrir o que mudou e escrever os UPDATE necessários. Esse é o dirty checking.

O dirty checking é ótimo. É o que permite escrever isto e o banco ser atualizado sem ninguém chamar save():

@Transactional
public void aplicarDesconto(StatusPedido status, BigDecimal desconto) {
    repository.findByStatus(status)
              .forEach(pedido -> pedido.aplicarDesconto(desconto));
}

O problema é pagar por esse mecanismo numa tela que só lê.


O readOnly = true já resolve boa parte

Se o seu método de leitura está assim, você já ganhou bastante:

@Transactional(readOnly = true)
public List<LinhaTabela> listar(StatusPedido status) { ... }

Desde o Spring Framework 5.1, o readOnly = true não se limita a suspender o flush. Ele também propaga o modo somente leitura para a sessão do Hibernate, chamando setDefaultReadOnly(true). Com isso o snapshot é descartado logo após o carregamento, e o dirty checking não roda para aquelas entidades.

Então, se você chegou até aqui e já usa readOnly = true nas leituras, ótimo. Metade da conta some. Vale dizer isso com todas as letras, porque muito conteúdo sobre projeção vende o benefício do snapshot como se fosse exclusivo dos DTOs, e não é bem assim.

O que interessa é o que sobra.


O que sobra depois do readOnly

Todas as colunas continuam vindo. O SELECT gerado traz a linha inteira da tabela, incluindo campos grandes que a tela nem exibe. Numa listagem de 50 registros isso já pesa. Numa exportação de 50 mil, pesa muito.

Os relacionamentos continuam no caminho. Se alguém no serviço tocar em pedido.getItens() dentro de um laço, você tem uma consulta por pedido. É o N+1 clássico, e ele nasce justamente de ter a entidade em mãos, com todas as portas abertas.

Os objetos continuam vivos no contexto. Mesmo sem snapshot, cada entidade carregada ocupa espaço no contexto de persistência até a transação terminar.

O contrato fica ambíguo. Uma entidade que sai do serviço pode ser serializada com proxy preguiçoso, pode ser alterada por engano num ponto distante, pode expor campo que não deveria aparecer na API. Quem lê a assinatura do método não sabe o que vai receber de verdade.

A projeção resolve os quatro de uma vez, porque o objeto que chega no seu código nunca foi uma entidade.


A projeção em Record

Um Record é imutável, tem construtor canônico e não precisa de configuração extra. Isso o torna um destino natural para o resultado de uma consulta de leitura.

Começa pelo tipo:

public record ResumoPedido(String numero, BigDecimal valorTotal, LocalDateTime criadoEm) {}

A partir daí existem três caminhos.


Caminho 1: query derivada

O mais curto. O Spring Data lê o tipo de retorno, encontra o construtor do Record e monta a consulta trazendo só as colunas correspondentes:

public interface PedidoRepository extends JpaRepository<Pedido, Long> {

    List<ResumoPedido> findResumoByStatus(StatusPedido status);
}

Isso funciona porque numero, valorTotal e criadoEm existem como propriedades em Pedido. Os nomes precisam casar. Se você renomear um componente do Record para total, a consulta para de resolver e você vai precisar do caminho 2.


Caminho 2: constructor expression

Quando a projeção envolve join, alias, função de agregação ou nome diferente, você escreve a JPQL:

@Query("""
        select new com.loja.pedido.ResumoPedido(p.numero, p.valorTotal, p.criadoEm)
        from Pedido p
        where p.status = :status
        order by p.criadoEm desc
        """)
List<ResumoPedido> buscarResumo(@Param("status") StatusPedido status);

Repare no nome totalmente qualificado dentro da string. É por isso que vale deixar cada Record no seu próprio arquivo. Record declarado dentro de outra classe vira algo como com.loja.pedido.Wrapper$ResumoPedido, e essa referência aninhada costuma dar dor de cabeça no parser.


Caminho 3: com componente @Embeddable

A partir do Hibernate 6.2, um Record pode ser um componente embutido:

@Embeddable
public record Endereco(String cep, String logradouro, String cidade) {}
@Entity
public class Pedido {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String numero;
    private BigDecimal valorTotal;

    @Embedded
    private Endereco entrega;
}

E esse componente pode viajar inteiro dentro da projeção:

public record ResumoEntrega(String numero, BigDecimal valorTotal, Endereco entrega) {}
@Query("""
        select new com.loja.pedido.ResumoEntrega(p.numero, p.valorTotal, p.entrega)
        from Pedido p
        where p.entrega.cidade = :cidade
        """)
List<ResumoEntrega> buscarResumoPorCidade(@Param("cidade") String cidade);

Uma consequência prática do Record como embeddable: o Hibernate instancia pelo construtor canônico e nunca altera o objeto no lugar. Para mudar o endereço de um pedido você troca o componente inteiro.

public void redirecionarPara(Endereco novoEndereco) {
    this.entrega = novoEndereco;
}

Isso incomoda no começo e depois vira alívio, porque endereço de entrega passa a ter um único ponto de mudança.


Qual caminho usar

Query derivada para listagem simples de uma tabela só, quando os nomes casam. É o mais legível e o que menos código exige.

Constructor expression quando entra join, agregação, alias ou nome diferente. Um pouco mais verboso, e em compensação explícito sobre o que vai ao banco.

Componente embutido quando um grupo de campos anda sempre junto, como endereço, contato ou faixa de datas.


Armadilhas que vale conhecer antes

Projeção não serve para escrita. O Record não está no contexto de persistência, então alterar um valor nele não muda nada no banco. Isso é o comportamento desejado, e vira problema só quando alguém tenta usar uma projeção onde precisava da entidade. Para mudar estado, buscar a entidade continua sendo o caminho certo.

Validação de invariante pede a entidade. Se a regra depende dos itens, do histórico ou de qualquer parte do agregado, carregue o agregado. Projeção é para leitura de dados, não para decisão de negócio.

open-in-view mascara o problema. Com ele ligado, que é o padrão do Spring Boot, a sessão fica aberta até a resposta ser renderizada e o lazy loading acontece em silêncio. Desligue em application.yml e o N+1 aparece na cara, ainda em desenvolvimento.

spring:
  jpa:
    open-in-view: false

A transação nasce no serviço. Anotar método a método no repositório funciona, e ainda assim quem conhece o escopo real da operação é o serviço. Deixe o repositório sem @Transactional.


Como medir na sua aplicação

Nada disso precisa ser aceito por confiança. O Hibernate expõe os contadores:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

E você lê os números direto do SessionFactory:

Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();

stats.clear();
service.listarComProjecao(StatusPedido.PAGO);

System.out.println(stats.getEntityLoadCount());       // entidades carregadas
System.out.println(stats.getCollectionLoadCount());   // coleções carregadas
System.out.println(stats.getPrepareStatementCount()); // statements preparados

No caminho da entidade, o contador de entidades carregadas acompanha o tamanho da lista. Nos três caminhos de projeção, ele fica em zero. Deixe ligado só em desenvolvimento, porque a coleta de estatísticas tem custo.


Um detalhe aqui

Entidade para escrita, transação e mudança de estado.

Record para tela, relatório, busca, exportação e retorno de API.

Não precisa refatorar tudo hoje. Pegue a consulta mais lenta do seu sistema, aquela que já apareceu num print do suporte, e troque só ela. Meça antes e depois. Com o número na mão, a conversa com o time fica muito mais fácil.


Projeto de apoio

O código completo deste artigo está no GitHub, com Java 21, Spring Boot 3.5, Hibernate 6 e H2 em memória. É clonar e rodar, sem configurar banco. Ao subir, um runner popula 50 pedidos e imprime as estatísticas dos quatro caminhos lado a lado, mais um cenário mostrando o UPDATE sair sozinho pelo dirty checking.

Repositório do projeto: Spring + JPA + Records


Este artigo faz parte da série de Records em Spring · JPA · jOOQ do getcaramelo.dev.

Tem dúvidas ou quer aprofundar algum ponto? Me encontra no LinkedIn ou no GitHub.

Gostou do conteúdo? Me segue no @getcaramelo.dev

← Voltar aos Artigos