Extensão — Objetos no Banco: JPA e Hibernate

Extensão — Objetos no Banco: JPA e Hibernate

Nesta aula as trilhas passam a ser gravadas a partir de anotações nas próprias classes. Você vai mapear entidades, relacionamentos e coleções com o JPA, entender o contexto de persistência construindo um em miniatura, ver a verificação de mudanças gravar sem nenhum "salvar" e medir o problema N+1.
Java

• • 34 min de leitura

Nesta aula as trilhas passam a ser gravadas a partir de anotações nas próprias classes. Você vai mapear entidades, relacionamentos e coleções com o JPA, entender o contexto de persistência construindo um em miniatura, ver a verificação de mudanças gravar sem nenhum "salvar" e medir o problema N+1.

"Spring Boot pela Primeira Vez" tirou a cerimônia do JDBC, mas deixou o trabalho: cada coluna ainda aparece três vezes, no INSERT, no SELECT e no RowMapper, e juntar uma trilha com seus pontos de interesse ainda exige consultas e laços escritos à mão. A diferença entre o mundo dos objetos, com referências, listas e herança, e o mundo das tabelas, com linhas, chaves e junções, tem até nome: descompasso objeto-relacional (object-relational impedance mismatch). As ferramentas de mapeamento objeto-relacional, os ORMs, existem para atravessar essa distância automaticamente. Em Java, o padrão é o Jakarta Persistence, conhecido pela sigla antiga JPA, e a implementação mais usada é o Hibernate, que "Java no Servidor" apresentou como a reação de Gavin King aos EJBs. Esta aula mostra como usá-los e, como o Farol e o contêiner em miniatura, o que eles fazem por baixo.

O que um ORM precisa resolver

O descompasso tem várias faces, e cada uma é um recurso do JPA. A identidade: em Java, dois objetos podem ser iguais com equals e ainda assim diferentes com ==, como "Iguais ou o Mesmo?" mostrou; no banco, uma linha é identificada pela chave primária, e o ORM precisa garantir que a mesma linha corresponda ao mesmo objeto. As associações: um objeto aponta para outro por uma referência, e pode ter uma lista deles; no banco, a ligação é uma chave estrangeira, e sempre do lado "muitos". A granularidade: uma Duration é um objeto em Java e um número de minutos no banco. E a navegação: em Java, ir de uma trilha às suas avaliações é seguir uma referência, de graça; no banco, é uma consulta, que custa uma ida ao servidor. Essa última face é a fonte dos problemas de desempenho mais comuns com ORMs, e o fim da aula é dedicado a ela.

O JPA é uma especificação, parte da Jakarta EE, e a versão atual, a 3.2, faz parte da Jakarta EE 11. O Hibernate ORM 7, que o Spring Boot 4 usa, a implementa, e acrescenta recursos próprios. O código desta aula usa as anotações do padrão, do pacote jakarta.persistence, e só recorre às do Hibernate quando o padrão não oferece o necessário.

Entidades

Uma classe mapeada para uma tabela é uma entidade. A trilha, como entidade:

package br.com.exemplo.trilhas;

import jakarta.persistence.*;
import org.hibernate.annotations.ListIndexBase;

import java.time.Duration;
import java.util.*;

@Entity
@Table(name = "trilha")
public class Trilha {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)      // o banco gera o id
    private Long id;

    @Column(nullable = false, unique = true)
    private String nome;

    @Column(nullable = false)
    private String regiao;

    @Column(name = "distancia_km", nullable = false)
    private double distanciaKm;

    @Enumerated(EnumType.STRING)                              // grava "FACIL", não 0
    @Column(nullable = false)
    private Dificuldade dificuldade;

    @Convert(converter = MinutosConverter.class)
    @Column(name = "duracao_min", nullable = false)
    private Duration duracao;

    @ElementCollection
    @CollectionTable(name = "ponto_de_interesse", joinColumns = @JoinColumn(name = "trilha_id"))
    @OrderColumn(name = "ordem")
    @ListIndexBase(1)                                         // a coluna ordem começa em 1, como no esquema
    @Column(name = "nome")
    private List<String> pontosDeInteresse = new ArrayList<>();

    @OneToMany(mappedBy = "trilha", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Avaliacao> avaliacoes = new ArrayList<>();

    protected Trilha() {
        // exigido pelo JPA: o Hibernate cria o objeto vazio e preenche os campos
    }

    public Trilha(String nome, String regiao, double distanciaKm, Dificuldade dificuldade,
                  Duration duracao, List<String> pontosDeInteresse) {
        if (distanciaKm <= 0) throw new IllegalArgumentException("distância deve ser positiva: " + distanciaKm);
        this.nome = Objects.requireNonNull(nome);
        this.regiao = Objects.requireNonNull(regiao);
        this.distanciaKm = distanciaKm;
        this.dificuldade = Objects.requireNonNull(dificuldade);
        this.duracao = Objects.requireNonNull(duracao);
        this.pontosDeInteresse.addAll(pontosDeInteresse);
    }

    public Avaliacao avaliar(String autor, int nota, String comentario, java.time.LocalDate data) {
        Avaliacao a = new Avaliacao(this, autor, nota, comentario, data);
        avaliacoes.add(a);                                    // mantém os dois lados da associação coerentes
        return a;
    }

    public void mudarDuracao(Duration nova) {
        this.duracao = Objects.requireNonNull(nova);
    }

    public Long getId() { return id; }
    public String getNome() { return nome; }
    public String getRegiao() { return regiao; }
    public double getDistanciaKm() { return distanciaKm; }
    public Dificuldade getDificuldade() { return dificuldade; }
    public Duration getDuracao() { return duracao; }
    public List<String> getPontosDeInteresse() { return Collections.unmodifiableList(pontosDeInteresse); }
    public List<Avaliacao> getAvaliacoes() { return Collections.unmodifiableList(avaliacoes); }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Trilha outra)) return false;
        return id != null && id.equals(outra.id);             // sem id, só é igual a si mesma
    }

    @Override
    public int hashCode() {
        return Trilha.class.hashCode();                       // constante: não muda quando o id é gerado
    }
}

A primeira surpresa é que a trilha deixou de ser um record. O JPA exige de uma entidade coisas que um record não pode oferecer: um construtor sem parâmetros, para que o Hibernate crie o objeto vazio e preencha os campos depois; campos que não sejam final, porque são preenchidos e alterados depois da criação; e uma classe que não seja final, porque o Hibernate cria subclasses em tempo de execução para implementar o carregamento preguiçoso. Uma entidade é, por natureza, um objeto mutável com identidade, o oposto do que "Contratos à Vista" recomenda para valores. Isso não é contradição: uma entidade representa algo que muda ao longo do tempo, como uma trilha cuja duração é revista, e os records continuam sendo a ferramenta certa para o que é valor, como os resultados de consultas mais adiante. O construtor sem parâmetros é protected, para que só o Hibernate o use, e o construtor público continua validando os dados.

As anotações dizem ao Hibernate como traduzir cada campo. @Entity marca a classe, @Table dá o nome da tabela, e @Id com @GeneratedValue(strategy = IDENTITY) diz que a chave primária é gerada pelo banco, como o INTEGER PRIMARY KEY do SQLite. @Column dá o nome e as restrições da coluna, que o Hibernate usaria se fosse criar a tabela. @Enumerated(EnumType.STRING) grava o nome da constante; o padrão, ORDINAL, grava a posição, 0, 1, 2, e uma constante nova inserida no meio do enum corromperia silenciosamente todos os dados já gravados. A duração usa um conversor, o próximo assunto.

Os pontos de interesse são uma @ElementCollection: uma coleção de valores simples, sem identidade própria, guardada numa tabela à parte, indicada por @CollectionTable. O @OrderColumn preserva a ordem da lista na coluna ordem, e o @ListIndexBase(1), do Hibernate, faz essa coluna começar em 1, como o esquema de "Dados que Sobrevivem" definiu; o padrão do JPA começaria em 0.

As avaliações, por outro lado, são entidades com vida própria, e a relação é um @OneToMany, a ser detalhada mais adiante. O cascade = CascadeType.ALL faz as operações sobre a trilha se propagarem às avaliações, e o orphanRemoval = true apaga do banco uma avaliação removida da lista. Os getters devolvem listas não modificáveis, e as mudanças passam por métodos com nome, como avaliar e mudarDuracao, pelo mesmo motivo de encapsulamento de "O que Fica Dentro, o que Fica Fora".

Igualdade de entidades

O equals e o hashCode de uma entidade são um problema famoso, e a solução acima merece explicação. Comparar todos os campos, como faz um record, estaria errado: uma entidade continua sendo a mesma trilha depois de ter a duração alterada. Comparar pelo id é o natural, mas o id é null até a trilha ser gravada, e passa a ter valor depois: se o hashCode dependesse do id, uma trilha guardada num HashSet antes de ser gravada mudaria de hashCode e ficaria perdida dentro do conjunto, o problema de "Iguais ou o Mesmo?". A solução acima, recomendada por especialistas em Hibernate como Vlad Mihalcea, é: duas trilhas são iguais se têm o mesmo id não nulo; uma trilha sem id só é igual a si mesma; e o hashCode é constante para a classe, o que respeita o contrato em todos os casos, ao custo de conjuntos grandes de entidades ficarem lentos, porque todas caem no mesmo compartimento. Na prática, conjuntos com milhares de entidades são raros e costumam indicar outro problema. O exercício 2 explora o que dá errado com as alternativas.

Conversores

O JPA sabe mapear os tipos comuns, como textos, números, datas e enums. Para os demais, ou quando o formato no banco não é o padrão, existem os conversores, classes que implementam AttributeConverter e traduzem nos dois sentidos:

package br.com.exemplo.trilhas;

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

import java.time.Duration;

/** Guarda uma Duration como um número inteiro de minutos. */
@Converter
public class MinutosConverter implements AttributeConverter<Duration, Long> {
    @Override
    public Long convertToDatabaseColumn(Duration duracao) {
        return duracao == null ? null : duracao.toMinutes();
    }

    @Override
    public Duration convertToEntityAttribute(Long minutos) {
        return minutos == null ? null : Duration.ofMinutes(minutos);
    }
}

O Hibernate saberia gravar uma Duration sozinho, mas não como um número inteiro de minutos, que é o que a coluna duracao_min do esquema guarda. A data das avaliações tem um problema parecido: o esquema guarda o texto ISO, e o driver do SQLite, por padrão, gravaria um número. Um segundo conversor resolve:

package br.com.exemplo.trilhas;

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

import java.time.LocalDate;

/** Guarda uma LocalDate como texto ISO (2026-09-29), o formato recomendado pelo SQLite. */
@Converter
public class DataIsoConverter implements AttributeConverter<LocalDate, String> {
    @Override
    public String convertToDatabaseColumn(LocalDate data) {
        return data == null ? null : data.toString();
    }

    @Override
    public LocalDate convertToEntityAttribute(String texto) {
        return texto == null ? null : LocalDate.parse(texto);
    }
}

Os dois tratam null explicitamente, porque o JPA os chama também para valores nulos.

Relacionamentos

A avaliação pertence a uma trilha, e a relação é mapeada dos dois lados:

package br.com.exemplo.trilhas;

import jakarta.persistence.*;

import java.time.LocalDate;
import java.util.Objects;

@Entity
@Table(name = "avaliacao")
public class Avaliacao {

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "trilha_id")
    private Trilha trilha;

    @Column(nullable = false)
    private String autor;

    @Column(nullable = false)
    private int nota;

    private String comentario;

    @Convert(converter = DataIsoConverter.class)
    @Column(nullable = false)
    private LocalDate data;

    protected Avaliacao() {}

    Avaliacao(Trilha trilha, String autor, int nota, String comentario, LocalDate data) {
        if (nota < 1 || nota > 5) throw new IllegalArgumentException("nota deve estar entre 1 e 5: " + nota);
        this.trilha = Objects.requireNonNull(trilha);
        this.autor = Objects.requireNonNull(autor);
        this.nota = nota;
        this.comentario = comentario;
        this.data = Objects.requireNonNull(data);
    }

    public Long getId() { return id; }
    public Trilha getTrilha() { return trilha; }
    public String getAutor() { return autor; }
    public int getNota() { return nota; }
    public String getComentario() { return comentario; }
    public LocalDate getData() { return data; }

    @Override
    public boolean equals(Object o) {
        return this == o || (o instanceof Avaliacao outra && id != null && id.equals(outra.id));
    }

    @Override
    public int hashCode() {
        return Avaliacao.class.hashCode();
    }
}

No banco, só existe uma ligação: a coluna trilha_id da tabela avaliacao. Em Java, há duas referências: a avaliação aponta para a trilha, com @ManyToOne, e a trilha tem a lista de avaliações, com @OneToMany. O JPA precisa saber qual das duas é a dona da relação, a que corresponde à coluna, e é o lado @ManyToOne, com o @JoinColumn. O mappedBy = "trilha" do outro lado diz "esta lista é o reflexo do campo trilha da avaliação", e alterações feitas só na lista, sem ajustar o campo, não são gravadas. Por isso o construtor da avaliação é de pacote, e a única forma de criar uma é pelo método avaliar da trilha, que preenche os dois lados de uma vez. Manter as duas pontas coerentes é responsabilidade do seu código, não do Hibernate.

O fetch = FetchType.LAZY no @ManyToOne é importante. Por padrão, o JPA carrega associações @ManyToOne de forma ansiosa (eager): ler uma avaliação traz junto a trilha, sempre, mesmo quando ninguém vai usá-la. Com LAZY, a trilha só é buscada quando for acessada. Associações @OneToMany e coleções de elementos já são preguiçosas por padrão. A regra prática, repetida por toda a comunidade Hibernate, é marcar todas as associações como LAZY e buscar junto, explicitamente, o que cada caso de uso precisa.

Configurando no Spring Boot

No projeto da aula anterior, o starter de JDBC dá lugar ao de JPA, que traz o Hibernate, e entra o suporte do Hibernate ao SQLite, que fica num módulo de dialetos mantido pela comunidade:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-community-dialects</artifactId>
</dependency>
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa-test</artifactId>
    <scope>test</scope>
</dependency>

A versão dos dialetos da comunidade é gerenciada pelo Spring Boot, junto com a do Hibernate. No application.properties, além das propriedades de conexão da aula anterior:

# O dialeto: como o Hibernate deve escrever SQL para o SQLite
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
# O esquema é nosso (schema.sql): o Hibernate não cria nem altera tabelas
spring.jpa.hibernate.ddl-auto=none
# Mostra no registro cada comando SQL que o Hibernate envia
logging.level.org.hibernate.SQL=debug
# Não manter a sessão aberta durante a renderização da resposta (explicado adiante)
spring.jpa.open-in-view=false

O dialeto ensina o Hibernate a escrever o SQL específico de cada banco: como gerar identificadores, como paginar, que tipos usar. O ddl-auto controla se o Hibernate deve criar ou alterar as tabelas a partir das entidades. Os valores create e update são tentadores em projetos pequenos, e perigosos em qualquer banco com dados de verdade, porque o Hibernate não sabe migrar dados; o esquema continua sendo o schema.sql desta vez, e na próxima aula, "O Banco que Evolui", passa a ser responsabilidade do Flyway, com o ddl-auto=validate verificando que entidades e tabelas concordam. O registro do SQL é a ferramenta mais importante para trabalhar com um ORM: o Hibernate escreve os comandos por você, e você precisa ver quais são.

O contexto de persistência

A peça central do JPA é o EntityManager, e o conceito central é o contexto de persistência que ele gerencia: o conjunto de entidades que ele carregou ou gravou durante uma unidade de trabalho, tipicamente uma transação. Três comportamentos decorrem dele, e o mais eficiente para entendê-los é construí-los em miniatura, com JDBC puro:

import java.sql.*;
import java.util.*;

/** Uma entidade: mutável, com identidade, como o JPA exige. */
class TrilhaEntidade {
    private final long id;
    private String nome;
    private int duracaoMin;

    TrilhaEntidade(long id, String nome, int duracaoMin) { this.id = id; this.nome = nome; this.duracaoMin = duracaoMin; }
    long getId() { return id; }
    String getNome() { return nome; }
    int getDuracaoMin() { return duracaoMin; }
    void setDuracaoMin(int d) { this.duracaoMin = d; }
}

/**
 * Um contexto de persistência em miniatura: guarda cada entidade lida (mapa de identidade),
 * tira uma "foto" do estado original e, no fim, grava só o que mudou (verificação de sujeira).
 */
final class ContextoDePersistencia implements AutoCloseable {
    private record Foto(String nome, int duracaoMin) {}

    private final Connection conexao;
    private final Map<Long, TrilhaEntidade> gerenciadas = new HashMap<>();
    private final Map<Long, Foto> fotos = new HashMap<>();
    int consultas = 0;

    ContextoDePersistencia(Connection conexao) throws SQLException {
        this.conexao = conexao;
        conexao.setAutoCommit(false);                        // o contexto vive dentro de uma transação
    }

    TrilhaEntidade encontrar(long id) throws SQLException {
        TrilhaEntidade ja = gerenciadas.get(id);
        if (ja != null) return ja;                           // mesma linha, mesmo objeto: sem ir ao banco
        consultas++;
        try (PreparedStatement ps = conexao.prepareStatement("SELECT nome, duracao_min FROM trilha WHERE id = ?")) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (!rs.next()) return null;
                TrilhaEntidade t = new TrilhaEntidade(id, rs.getString(1), rs.getInt(2));
                gerenciadas.put(id, t);
                fotos.put(id, new Foto(t.getNome(), t.getDuracaoMin()));
                return t;
            }
        }
    }

    /** Compara cada entidade com a sua foto e manda um UPDATE só para as que mudaram. */
    void descarregar() throws SQLException {
        for (TrilhaEntidade t : gerenciadas.values()) {
            Foto antes = fotos.get(t.getId());
            Foto agora = new Foto(t.getNome(), t.getDuracaoMin());
            if (antes.equals(agora)) continue;
            try (PreparedStatement ps = conexao.prepareStatement("UPDATE trilha SET nome = ?, duracao_min = ? WHERE id = ?")) {
                ps.setString(1, t.getNome());
                ps.setInt(2, t.getDuracaoMin());
                ps.setLong(3, t.getId());
                ps.executeUpdate();
                System.out.println("  UPDATE trilha ... WHERE id = " + t.getId() + "   (" + antes + " → " + agora + ")");
            }
            fotos.put(t.getId(), agora);
        }
    }

    @Override public void close() throws SQLException {
        descarregar();
        conexao.commit();
        conexao.close();
    }
}

Usando o contexto numa tabela com três trilhas:

try (var ctx = new ContextoDePersistencia(DriverManager.getConnection(url))) {
    TrilhaEntidade a = ctx.encontrar(1);
    TrilhaEntidade b = ctx.encontrar(1);
    System.out.println("a == b? " + (a == b) + ", consultas: " + ctx.consultas);
    ctx.encontrar(2);
    ctx.encontrar(3);
    a.setDuracaoMin(135);                             // nenhum "salvar": só um setter
    ctx.encontrar(3).setDuracaoMin(60);               // "altera" para o mesmo valor
    System.out.println("fechando o contexto:");
}
a == b? true, consultas: 1
fechando o contexto:
  UPDATE trilha ... WHERE id = 1   (Foto[nome=Lagoinha do Leste, duracaoMin=120] → Foto[nome=Lagoinha do Leste, duracaoMin=135])
  no banco: 1 → 135 min
  no banco: 2 → 210 min
  no banco: 3 → 60 min

Esta miniatura, executada sobre o SQLite, mostra os três comportamentos. O primeiro é o mapa de identidade: buscar a mesma linha duas vezes devolve o mesmo objeto, a == b, com uma única consulta; é o que garante que, dentro de uma transação, não existam duas cópias da mesma trilha com estados diferentes. O segundo é a verificação de sujeira (dirty checking): ao ser lida, cada entidade tem seu estado fotografado; no fim, o contexto compara cada entidade com a sua foto e grava apenas as que mudaram. A trilha 1 recebeu um UPDATE sem que ninguém chamasse "salvar": bastou um setter. A trilha 3, "alterada" para o mesmo valor, não gerou comando nenhum, e a 2, que não foi tocada, também não. O terceiro é o descarregamento (flush): as mudanças ficam acumuladas na memória e são enviadas ao banco de uma vez, antes do commit.

O Hibernate faz exatamente isso, com muito mais cuidado: fotografa todos os campos, entende associações e coleções, ordena os comandos para respeitar as chaves estrangeiras, e descarrega também antes de consultas que poderiam ser afetadas pelas mudanças pendentes. Mas a lógica é essa, e entender a miniatura explica comportamentos que, sem ela, parecem mágica ou defeito.

O repositório com EntityManager

Com as entidades mapeadas, o repositório encolhe:

package br.com.exemplo.trilhas;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class RepositorioDeTrilhas {

    @PersistenceContext
    private EntityManager em;       // um proxy: cada transação usa o seu próprio contexto de persistência

    public Trilha salvar(Trilha nova) {
        em.persist(nova);             // agendada para INSERT; o id é preenchido
        return nova;
    }

    public Optional<Trilha> buscar(long id) {
        return Optional.ofNullable(em.find(Trilha.class, id));
    }

    public List<Trilha> listarPorRegiao(String regiao) {
        return em.createQuery("SELECT t FROM Trilha t WHERE t.regiao = :regiao ORDER BY t.nome", Trilha.class)
                .setParameter("regiao", regiao)
                .getResultList();
    }

    public List<Trilha> listarComAvaliacoes(String regiao) {
        return em.createQuery("""
                SELECT t FROM Trilha t
                  LEFT JOIN FETCH t.avaliacoes
                 WHERE t.regiao = :regiao
                 ORDER BY t.nome
                """, Trilha.class)
                .setParameter("regiao", regiao)
                .getResultList();
    }

    public List<ResumoDeTrilha> resumos() {
        return em.createQuery("""
                SELECT new br.com.exemplo.trilhas.ResumoDeTrilha(t.nome, COUNT(a), AVG(a.nota))
                  FROM Trilha t LEFT JOIN t.avaliacoes a
                 GROUP BY t.id, t.nome
                 ORDER BY t.nome
                """, ResumoDeTrilha.class)
                .getResultList();
    }
}
package br.com.exemplo.trilhas;

/** O resultado de uma consulta de agregação: um valor, e portanto um record. */
public record ResumoDeTrilha(String nome, long avaliacoes, Double media) {}

O @PersistenceContext injeta um EntityManager que é, na verdade, um proxy: a cada transação, ele delega a um contexto de persistência próprio, associado à thread, o que permite compartilhar o repositório entre todas as requisições. O persist coloca a trilha nova no contexto e agenda o INSERT; com a geração de id por IDENTITY, o Hibernate executa o INSERT na hora, para obter o id gerado pelo banco. O find busca por chave primária, passando antes pelo mapa de identidade.

As consultas usam a JPQL (Jakarta Persistence Query Language), uma linguagem parecida com o SQL, mas que fala de entidades e campos, e não de tabelas e colunas: Trilha t é a classe, t.regiao é o campo, e t.avaliacoes é a associação, que o Hibernate traduz para o JOIN com a condição certa. Os parâmetros com nome, como :regiao, protegem contra a injeção de SQL, exatamente como os marcadores do JDBC. A terceira consulta mostra uma expressão de construtor, SELECT new ..., que devolve records em vez de entidades: para um resumo, que não vai ser alterado nem gravado, um record é mais leve e mais honesto que uma entidade.

O serviço, sem "salvar"

O serviço mostra a verificação de sujeira na prática:

package br.com.exemplo.trilhas;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.LocalDate;
import java.util.List;
import java.util.NoSuchElementException;

@Service
public class ServicoDeTrilhas {
    private final RepositorioDeTrilhas repositorio;

    public ServicoDeTrilhas(RepositorioDeTrilhas repositorio) {
        this.repositorio = repositorio;
    }

    @Transactional
    public Trilha cadastrar(Trilha nova) {
        return repositorio.salvar(nova);
    }

    @Transactional
    public void mudarDuracao(long id, Duration duracao) {
        Trilha t = repositorio.buscar(id).orElseThrow(() -> new NoSuchElementException("trilha " + id));
        t.mudarDuracao(duracao);            // nenhuma chamada de "salvar": o commit grava a mudança
    }

    @Transactional
    public Avaliacao avaliar(long id, String autor, int nota, String comentario) {
        Trilha t = repositorio.buscar(id).orElseThrow(() -> new NoSuchElementException("trilha " + id));
        return t.avaliar(autor, nota, comentario, LocalDate.now());   // gravada pelo cascade
    }

    @Transactional(readOnly = true)
    public List<ResumoDeTrilha> resumos() {
        return repositorio.resumos();
    }
}

O mudarDuracao não chama nada depois do setter: no fim da transação, o Hibernate compara a trilha com a foto tirada na leitura e envia o UPDATE. O avaliar acrescenta uma avaliação à lista da trilha, e o cascade = ALL faz o persist se propagar a ela. Com o registro de SQL ligado, o mudarDuracao produz algo como:

select t1_0.id,t1_0.dificuldade,t1_0.distancia_km,t1_0.duracao_min,t1_0.nome,t1_0.regiao from trilha t1_0 where t1_0.id=?
update trilha set dificuldade=?,distancia_km=?,duracao_min=?,nome=?,regiao=? where id=?

Os apelidos como t1_0 são gerados pelo Hibernate. Por padrão, o UPDATE inclui todas as colunas da entidade, e não só a alterada, o que permite reaproveitar o mesmo comando preparado para qualquer mudança; a anotação @DynamicUpdate, do Hibernate, muda esse comportamento quando necessário.

O @Transactional(readOnly = true) nos métodos que só leem é mais que documentação: o Spring avisa o Hibernate, que deixa de fotografar as entidades e de verificar mudanças, economizando memória e tempo, e evita gravações acidentais, tema do exercício 5.

Estados de uma entidade

Com o contexto de persistência, uma entidade passa por estados, e saber em qual ela está explica quase todo comportamento estranho do JPA. Uma entidade criada com new é transiente (transient): o Hibernate não sabe que ela existe. Depois do persist, ou quando é lida por find ou por uma consulta, ela é gerenciada (managed): está no contexto, e suas mudanças são detectadas. Quando a transação termina e o contexto é fechado, ela fica desligada (detached): continua existindo na memória, com os dados que tinha, mas mudanças nela não são mais detectadas, e associações preguiçosas que não foram carregadas não podem mais ser carregadas. E depois de um remove, ela fica removida, agendada para DELETE.

A consequência mais visível do estado desligado é a exceção mais famosa do Hibernate:

Trilha t = servico.buscar(1).orElseThrow();       // a transação terminou dentro do serviço
t.getAvaliacoes().size();                          // LazyInitializationException: could not initialize proxy - no Session

A trilha foi lida dentro de uma transação, e as avaliações, preguiçosas, não vieram junto. Fora da transação, o contexto já foi fechado, e não há mais conexão para buscá-las. As soluções são buscar junto o que se vai usar, com o JOIN FETCH da consulta listarComAvaliacoes, ou converter a entidade em um record com os dados necessários dentro da transação, que é o que "A API das Trilhas" vai fazer.

Existe um atalho que esconde esse problema, e o Spring Boot o liga por padrão: o Open Session in View, que mantém o contexto de persistência aberto durante toda a requisição, inclusive durante a conversão da resposta em JSON. Com ele, o acesso preguiçoso fora do serviço funciona, mas cada associação acessada na conversão dispara uma consulta, fora de qualquer transação e longe de qualquer controle, e o Spring Boot emite um aviso na inicialização por causa disso. A propriedade spring.jpa.open-in-view=false, no application.properties desta aula, desliga o atalho e faz os problemas aparecerem cedo, onde podem ser resolvidos.

O problema N+1

O carregamento preguiçoso tem uma armadilha que merece uma seção só para ela. Considere uma tela que lista as trilhas com a média das avaliações de cada uma, escrita da forma mais natural com as entidades:

for (Trilha t : repositorio.listarPorRegiao("Sul"))
    System.out.println(t.getNome() + ": " + t.getAvaliacoes().stream().mapToInt(Avaliacao::getNota).average());

O código parece inofensivo, e executa uma consulta para as trilhas e mais uma para cada trilha, na primeira vez que getAvaliacoes() é acessado: com 500 trilhas, 501 consultas. Daí o nome: N+1. Para medir o efeito, sem Hibernate, um programa sobre o SQLite com 500 trilhas e 10 avaliações cada, e o índice em trilha_id de "Dados que Sobrevivem", compara o carregamento preguiçoso, simulado com uma consulta por trilha, com uma única consulta com JOIN e agregação:

preguiçoso: 501 consultas, 23 ms | com JOIN: 1 consulta, 7 ms | mesmos resultados? true
preguiçoso: 501 consultas, 4 ms | com JOIN: 1 consulta, 2 ms | mesmos resultados? true
preguiçoso: 501 consultas, 3 ms | com JOIN: 1 consulta, 1 ms | mesmos resultados? true

Com um banco embutido, que roda no mesmo processo, cada consulta custa microssegundos, e a diferença parece pequena: 3 contra 1 ms, depois do aquecimento. Esse é justamente o perigo. Com um banco servidor, como o PostgreSQL da trilha, cada consulta é uma ida e volta pela rede, que custa tipicamente de meio a alguns milissegundos: as 501 consultas passam a custar de 250 ms a mais de um segundo, enquanto a consulta única continua custando um ou dois milissegundos. O N+1 fica invisível no desenvolvimento, com um banco local e poucos dados, e aparece em produção, com a rede e o volume reais. É por isso que o registro do SQL precisa estar ligado durante o desenvolvimento: 501 linhas de select para uma tela são impossíveis de não notar.

As soluções dependem do que a tela precisa. Se precisa das entidades com as avaliações, o JOIN FETCH busca tudo numa consulta, como em listarComAvaliacoes; o Hibernate 6 e o 7 eliminam automaticamente as trilhas repetidas que o JOIN produz. Se precisa só de números, a consulta de agregação com um record, como resumos, é ainda melhor, porque nem traz as avaliações para a memória. E há recursos do Hibernate, como o carregamento em lote (@BatchSize), que reduzem as N consultas a poucas, buscando as coleções de várias entidades de uma vez.

Testando

O Spring Boot tem uma fatia de testes para JPA, o @DataJpaTest, que sobe só as partes de persistência, sem a camada web, e executa cada teste numa transação desfeita no fim:

package br.com.exemplo.trilhas;

import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)   // o SQLite de verdade, não um banco em memória
@Import(RepositorioDeTrilhas.class)
class RepositorioDeTrilhasTest {

    @TempDir
    static Path pasta;

    @DynamicPropertySource
    static void banco(DynamicPropertyRegistry propriedades) {
        propriedades.add("spring.datasource.url", () -> "jdbc:sqlite:" + pasta.resolve("teste.db"));
        propriedades.add("spring.sql.init.mode", () -> "always");
    }

    @Autowired RepositorioDeTrilhas repositorio;
    @Autowired EntityManager em;

    @Test
    void gravaELeComPontosEAvaliacoes() {
        Trilha t = new Trilha("Lagoinha do Leste", "Sul", 4.5, Dificuldade.MODERADA, Duration.ofMinutes(120),
                List.of("Mirante do Matadeiro", "Pedra do Urubu"));
        t.avaliar("Ana", 5, "A vista compensa.", LocalDate.of(2026, 8, 2));
        repositorio.salvar(t);

        em.flush();   // envia os comandos ao banco
        em.clear();   // esvazia o contexto: a próxima leitura vem do banco, não do mapa de identidade

        Trilha lida = repositorio.buscar(t.getId()).orElseThrow();
        assertThat(lida).isNotSameAs(t);
        assertThat(lida.getDuracao()).isEqualTo(Duration.ofMinutes(120));
        assertThat(lida.getPontosDeInteresse()).containsExactly("Mirante do Matadeiro", "Pedra do Urubu");
        assertThat(lida.getAvaliacoes()).extracting(Avaliacao::getAutor).containsExactly("Ana");
    }

    @Test
    void resumoConta() {
        Trilha t = repositorio.salvar(new Trilha("Morro da Coroa", "Leste", 6.8, Dificuldade.DIFICIL,
                Duration.ofMinutes(210), List.of()));
        t.avaliar("Diego", 3, null, LocalDate.of(2026, 6, 11));
        t.avaliar("Bruno", 2, null, LocalDate.of(2026, 9, 10));
        em.flush();

        assertThat(repositorio.resumos())
                .contains(new ResumoDeTrilha("Morro da Coroa", 2, 2.5));
    }
}

Dois detalhes separam um teste de JPA útil de um que mente. O primeiro é o par flush() e clear(). Sem eles, o buscar devolveria a mesma instância que o teste acabou de criar, direto do mapa de identidade, sem ir ao banco, e o teste passaria mesmo com o mapeamento errado, como um conversor que gravasse nanossegundos em vez de minutos. O flush força os comandos a irem ao banco, e o clear esvazia o contexto, obrigando a leitura seguinte a reconstruir a trilha a partir das tabelas; o isNotSameAs confirma isso. O segundo é o @AutoConfigureTestDatabase(replace = NONE): por padrão, o @DataJpaTest troca o banco configurado por um banco em memória, se houver um no classpath, e o teste passaria a testar outro banco. Com NONE, ele usa o SQLite, num arquivo temporário. No Spring Boot 4, essas anotações mudaram de pacote, com a divisão do projeto em módulos, e os imports acima são os novos; exemplos de versões anteriores usam org.springframework.boot.test.autoconfigure.

O JPA trouxe o mapeamento automático, e com ele um modelo mental novo: entidades com estado, um contexto que as acompanha, gravações sem "salvar" e consultas que podem se multiplicar sem aviso. O repositório, porém, ainda é escrito à mão, com o EntityManager e a JPQL, e o esquema ainda é um script executado a cada inicialização.

Fontes e leituras recomendadas

  • Jakarta Persistence 3.2, especificação: o padrão que define entidades, associações, o EntityManager, o contexto de persistência e a JPQL. https://jakarta.ee/specifications/persistence/3.2/
  • Hibernate ORM, "User Guide": a documentação do Hibernate, com os capítulos sobre mapeamento de associações, carregamento (fetching) e o contexto de persistência. https://docs.hibernate.org/stable/orm/userguide/html_single/
  • Vlad Mihalcea, High-Performance Java Persistence: o livro de referência sobre desempenho com JPA e Hibernate, com o problema N+1, as estratégias de carregamento e a implementação de equals e hashCode usada nesta aula. O blog do autor, https://vladmihalcea.com, tem artigos gratuitos sobre cada tema.
  • Spring Boot, "Data" (documentação de referência): a configuração do JPA no Spring Boot, incluindo o Open Session in View e o @DataJpaTest. https://docs.spring.io/spring-boot/reference/data/sql.html
  • Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002): os padrões Identity Map, Unit of Work e Lazy Load, que o contexto de persistência e o carregamento preguiçoso implementam.

Exercícios

Exercício 1

Liste as exigências que o JPA faz de uma entidade e explique, uma a uma, por que um record não as cumpre. Onde os records continuam úteis numa aplicação com JPA?

Ver resposta

✓ Resposta: O JPA exige um construtor sem parâmetros, público ou protegido, porque o Hibernate cria a entidade vazia e preenche os campos com os valores lidos do banco; um record só tem o construtor canônico, com todos os componentes. Exige campos que não sejam final, porque a entidade é preenchida depois de criada e alterada ao longo da vida, e a verificação de sujeira depende exatamente dessas alterações; os campos de um record são final. Exige uma classe que não seja final, porque o Hibernate cria subclasses em tempo de execução, os proxies, para adiar o carregamento de associações preguiçosas; todo record é final. E uma entidade tem identidade própria, representada pelo id, enquanto a igualdade de um record é por valor, comparando todos os componentes, o que conflitaria com a igualdade de entidades discutida na aula. Os records continuam úteis em tudo o que é valor e não muda: resultados de consultas de agregação, como o ResumoDeTrilha; projeções que trazem só algumas colunas; os objetos de entrada e saída da API, que "A API das Trilhas" vai definir; e componentes embutidos, porque o JPA 3.2 permite usar records como @Embeddable, tipos de valor gravados em colunas da própria tabela da entidade.

Exercício 2

Considere três implementações de equals e hashCode para Trilha: (a) as geradas pelo IDE, com todos os campos; (b) as baseadas só no id, com Objects.hash(id); (c) a da aula. Mostre um cenário que quebra cada uma das duas primeiras.

Ver resposta

✓ Resposta: (a) Com todos os campos, uma trilha guardada num HashSet e depois alterada, por exemplo com mudarDuracao, muda de hashCode, e o conjunto deixa de encontrá-la: conjunto.contains(t) devolve false para o próprio objeto que está lá dentro. Pior, os campos incluem a lista de avaliações, e comparar duas trilhas forçaria o carregamento preguiçoso dessas listas, disparando consultas, ou uma LazyInitializationException fora da transação. E duas leituras da mesma linha em contextos diferentes, com os mesmos dados, seriam iguais, mas uma alteração numa delas as tornaria diferentes, embora continuem representando a mesma trilha. (b) Com o id, uma trilha nova tem id nulo: se for colocada num HashSet antes de ser gravada, entra no compartimento do hashCode de null; depois do persist, o id é preenchido e o hashCode muda, e o conjunto perde a trilha, o problema de "Iguais ou o Mesmo?". Além disso, duas trilhas novas, ambas com id nulo, seriam consideradas iguais pelo Objects.equals(id, outra.id), e um Set guardaria só uma delas. A solução da aula evita os dois problemas: o hashCode constante nunca muda, e a igualdade exige um id não nulo, então duas trilhas novas só são iguais a si mesmas.

Exercício 3

A página inicial do site vai mostrar, para cada trilha do Sul, o nome e a quantidade de avaliações. Um colega escreveu o código abaixo. Quantas consultas ele faz com 200 trilhas? Proponha duas correções e diga qual prefere.

@Transactional(readOnly = true)
public List<String> linhas() {
    return repositorio.listarPorRegiao("Sul").stream()
            .map(t -> t.getNome() + " (" + t.getAvaliacoes().size() + ")")
            .toList();
}
Ver resposta

✓ Resposta: Uma consulta para as trilhas e mais uma por trilha, na primeira chamada a getAvaliacoes() de cada uma: 201 consultas. É o problema N+1, e ainda com um agravante: para saber a quantidade, o código carrega todas as avaliações de cada trilha para a memória, com autor, comentário e data, só para contá-las. A primeira correção é buscar tudo junto, trocando listarPorRegiao por listarComAvaliacoes, com LEFT JOIN FETCH: uma consulta só, mas que continua trazendo todas as avaliações para a memória. A segunda é uma consulta de agregação que devolve exatamente o que a tela precisa:

public List<ResumoDeTrilha> resumosDaRegiao(String regiao) {
    return em.createQuery("""
            SELECT new br.com.exemplo.trilhas.ResumoDeTrilha(t.nome, COUNT(a), AVG(a.nota))
              FROM Trilha t LEFT JOIN t.avaliacoes a
             WHERE t.regiao = :regiao
             GROUP BY t.id, t.nome
             ORDER BY t.nome
            """, ResumoDeTrilha.class)
            .setParameter("regiao", regiao)
            .getResultList();
}

Prefiro a segunda: uma consulta, nenhuma entidade carregada, nenhuma foto para a verificação de sujeira, e o banco faz a contagem, que é o que ele faz de melhor. O JOIN FETCH é a escolha certa quando a tela precisa das entidades completas, por exemplo para exibir o texto das avaliações. A lição geral é que a forma dos dados buscados deve seguir o caso de uso, e não o formato das entidades.

Exercício 4

Um controlador chama servico.buscar(1), que devolve a entidade Trilha, e em seguida monta uma resposta com trilha.getAvaliacoes(). Com spring.jpa.open-in-view=false, isso lança LazyInitializationException. Explique por que, e por que ligar o Open Session in View não é a correção certa.

Ver resposta

✓ Resposta: O buscar do serviço é executado numa transação, que abre um contexto de persistência, lê a trilha e fecha o contexto ao terminar. A trilha chega ao controlador no estado desligado. As avaliações, preguiçosas, não foram carregadas: a lista da trilha é um objeto especial do Hibernate que sabe buscá-las, mas só enquanto o contexto e a conexão existirem. Fora da transação, a tentativa de carregá-las falha com "could not initialize proxy - no Session". Ligar o Open Session in View faz o erro desaparecer, porque mantém o contexto aberto até o fim da requisição, mas troca um erro visível por problemas invisíveis: as consultas preguiçosas passam a acontecer durante a montagem da resposta, fora de qualquer transação, uma por associação acessada, reproduzindo o N+1 de forma espalhada e difícil de encontrar; e a conexão com o banco fica ocupada durante toda a requisição, inclusive enquanto a resposta é escrita para um cliente lento, esgotando o pool sob carga. A correção certa é decidir, no serviço, o que a resposta precisa: buscar junto com JOIN FETCH ou, melhor, converter a entidade num record com os dados necessários dentro da transação, e devolver o record ao controlador. As entidades ficam dentro da camada de serviço, e o que sai dela são valores.

Exercício 5

Um método de relatório, anotado só com @Transactional, lê as trilhas e, para calcular uma estatística, arredonda a duração de cada uma com t.mudarDuracao(...) antes de somar. O relatório sai certo, mas o banco muda. Explique pela miniatura do contexto de persistência e proponha duas correções.

Ver resposta

✓ Resposta: As trilhas lidas pela consulta são entidades gerenciadas, fotografadas na leitura. O método altera a duração de cada uma na memória, pensando num cálculo local, mas para o contexto de persistência isso é indistinguível de uma alteração intencional: no fim da transação, o descarregar compara cada entidade com a sua foto, encontra durações diferentes e envia um UPDATE para cada trilha, como a miniatura fez com a trilha 1. O relatório "funciona", e todas as durações do catálogo ficam arredondadas, sem que nenhuma linha de código diga "salvar". A primeira correção é não alterar entidades para cálculos: ler o valor, arredondar numa variável local ou num record, e somar, deixando as entidades intactas; ou, melhor ainda, buscar só os números com uma consulta de agregação ou de projeção, sem entidades. A segunda é marcar o método com @Transactional(readOnly = true): o Spring configura o Hibernate para não descarregar mudanças, e as alterações acidentais não chegam ao banco. As duas se complementam: readOnly em todo método que só lê é uma rede de proteção barata, e evitar mutar entidades fora da intenção de gravar é o hábito que dispensa a rede.

Comentários

Mais em Java

Provando que Funciona — Testes com JUnit e AssertJ
Provando que Funciona — Testes com JUnit e AssertJ

Como escrever testes unitários com JUnit 6 e AssertJ: a estrutura preparar…

Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos
Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos

Nesta aula você mede quanto um jogo aloca por quadro, compara três jeitos de…

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids

Nesta aula você aprende a detectar colisões com círculos, retângulos e…