Spring Boot e Spring Cloud
Spring Data JPA com Spring Boot: repositórios, consultas, transações e migrações
Intermediário8 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Você vai saber persistir e consultar dados com Spring Data JPA em um serviço Spring Boot 3: definir entidades e repositórios, escrever consultas derivadas, JPQL e nativas, paginar e projetar resultados, posicionar as transações corretamente, evitar as armadilhas do N+1 e do open-in-view e gerenciar o schema com Flyway em vez de ddl-auto.
O que você vai aprender
- Configurar um DataSource, a JPA e o Hibernate via propriedades do Spring Boot e saber quais padrões mudar
- Escrever interfaces de repositório com consultas derivadas e métodos @Query e @Modifying
- Paginar, ordenar e projetar resultados sem carregar grafos de entidades inteiros
- Colocar @Transactional na camada certa e explicar o que muda em uma transação somente leitura
- Detectar e corrigir N+1 selects com fetch joins e @EntityGraph
- Gerenciar mudanças de schema com migrações Flyway em vez de hibernate.ddl-auto
O Spring Data JPA elimina a maior parte do código que o acesso a dados costumava exigir: uma declaração de interface substitui uma classe DAO, nomes de métodos substituem muitas consultas e o Spring Boot configura o DataSource, o Hibernate e o gerenciador de transações a partir de um punhado de propriedades. Essa mesma conveniência torna fácil colocar em produção um serviço que dispara centenas de consultas por requisição sem que ninguém perceba até o tráfego chegar. Esta página cobre a mecânica e, tão importante quanto, os pontos em que os padrões precisam ser alterados. O alvo é o Spring Boot 3 com Spring Data JPA 3 e Hibernate 6, sobre Jakarta Persistence 3.1.
O que o Boot configura para você
Adicionar spring-boot-starter-data-jpa e um driver JDBC traz um pool de conexões (HikariCP), um EntityManagerFactory baseado no Hibernate, um JpaTransactionManager e o scan de repositórios a partir do pacote da aplicação. A configuração mínima é uma URL e as credenciais:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: shop
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10 # ajuste ao limite de conexões do banco
jpa:
open-in-view: false # veja abaixo
properties:
hibernate:
default_batch_fetch_size: 50
flyway:
enabled: true
Dois padrões merecem uma decisão já no primeiro dia. spring.jpa.open-in-view é true por padrão e mantém o EntityManager aberto durante toda a requisição web para que views e serializadores JSON possam disparar carregamentos lazy. É conveniente, e é justamente por isso que o Boot registra um aviso na inicialização: ele esconde explosões de consultas e segura uma conexão com o banco enquanto a resposta é escrita. Desligue-o e carregue explicitamente o que precisar. spring.jpa.hibernate.ddl-auto tem como padrão create-drop para bancos embarcados e none nos demais casos; deixe em none e deixe as migrações cuidarem do schema. spring.jpa.show-sql escreve o SQL na saída padrão sem passar pelo framework de logging; prefira logging.level.org.hibernate.SQL=DEBUG (e org.hibernate.orm.jdbc.bind=TRACE no Hibernate 6 para ver os parâmetros vinculados).
Entidades e identificadores
Uma entidade é uma classe mapeada para uma tabela. O exemplo abaixo mostra as escolhas que importam mais do que as próprias anotações:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "orders_seq")
@SequenceGenerator(name = "orders_seq", sequenceName = "orders_seq", allocationSize = 50)
private Long id;
@Version
private long version; // lock otimista
@Enumerated(EnumType.STRING) // nunca ORDINAL: reordenar o enum corrompe os dados
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY) // to-one é EAGER por padrão; sobrescreva
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
private Instant createdAt;
protected Order() {} // a JPA precisa de um construtor sem argumentos; não o deixe público
// métodos de negócio, getters; sem setter para id nem version
}
SEQUENCE com um tamanho de alocação permite ao Hibernate atribuir ids em lotes e habilita o batching JDBC dos inserts; IDENTITY força uma ida e volta ao banco por linha e desativa o batching de inserts. @Version transforma atualizações concorrentes em uma OptimisticLockException (exposta como a ObjectOptimisticLockingFailureException do Spring) em vez de atualizações perdidas silenciosamente. Todo @ManyToOne e @OneToOne deve ser LAZY; o padrão EAGER que a especificação define para associações to-one é, de longe, a maior fonte de joins acidentais.
Não implemente equals/hashCode sobre ids de banco que ficam null até a entidade ser salva; use uma chave de negócio ou a identidade baseada na classe descrita na documentação do Hibernate. Records não podem ser entidades (são final e imutáveis), mas são ideais para os DTOs e as projeções que saem da camada de persistência.
Repositórios
Um repositório é uma interface; o Spring Data gera a implementação na inicialização:
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
// consulta derivada: interpretada a partir do nome do método na inicialização
List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(Long customerId, OrderStatus status);
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status);
// JPQL com fetch join para evitar o N+1 nas linhas
@Query("select o from Order o join fetch o.lines where o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
// atualização em massa: ignora o contexto de persistência, daí o clearAutomatically
@Modifying(clearAutomatically = true)
@Query("update Order o set o.status = :status where o.createdAt < :before and o.status = 'NEW'")
int expireStaleOrders(@Param("status") OrderStatus status, @Param("before") Instant before);
// SQL nativo quando o banco faz melhor
@Query(value = """
select customer_id, count(*) as orders, sum(total_cents) as revenue_cents
from orders where created_at >= :since
group by customer_id order by revenue_cents desc limit :limit
""", nativeQuery = true)
List<CustomerRevenue> topCustomers(@Param("since") Instant since, @Param("limit") int limit);
}
JpaRepository estende ListCrudRepository e ListPagingAndSortingRepository (as variantes que retornam List, introduzidas no Spring Data 3) e acrescenta recursos específicos da JPA, como flush() e saveAllAndFlush(). Se um repositório não deve expor deleteAll para toda a base de código, estenda Repository<T, ID> e declare apenas os métodos que quiser.
Os nomes das consultas derivadas são validados na inicialização, então um erro de digitação falha na hora, mas nomes longos ficam ilegíveis: passou de três condições, mude para @Query. As consultas JPQL também são validadas na inicialização contra o modelo de entidades. As nativas não, o que é mais um motivo para mantê-las raras e cobertas por testes contra o banco real.
Specification (de JpaSpecificationExecutor) monta cláusulas where dinâmicas com a Criteria API para telas de busca com muitos filtros opcionais, e o Query by Example do Spring Data cobre comparações simples por igualdade. Para qualquer coisa mais elaborada, Querydsl ou JPQL montada em um fragmento de repositório customizado fica mais claro do que aninhar specifications.
Paginação, ordenação e projeções
Pageable e Sort passam intactos do controller para o repositório; Page<T> traz a contagem total (ao custo de uma consulta count extra), enquanto Slice<T> só sabe se existe uma próxima página, o que sai mais barato para scroll infinito.
Carregar entidades completas para devolver três campos é desperdício. As projeções baseadas em interface deixam a consulta selecionar apenas o que foi declarado:
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
Instant getCreatedAt();
CustomerName getCustomer(); // projeção aninhada
interface CustomerName { String getName(); }
}
Page<OrderSummary> findByStatus(OrderStatus status, Pageable pageable);
As projeções baseadas em classe (DTO) usam um record e uma constructor expression, e são as mais eficientes porque o Hibernate seleciona exatamente os argumentos do construtor:
public record CustomerRevenue(Long customerId, long orders, long revenueCents) {}
@Query("select new com.example.shop.report.CustomerRevenue(o.customer.id, count(o), sum(o.totalCents)) "
+ "from Order o where o.createdAt >= :since group by o.customer.id")
List<CustomerRevenue> revenueSince(@Param("since") Instant since);
Transações e contexto de persistência
O SimpleJpaRepository do Spring Data é anotado com @Transactional(readOnly = true) no nível da classe, com sobrescritas de leitura e escrita nos métodos que alteram dados, então cada chamada ao repositório é transacional por si só. Operações de negócio que envolvem várias chamadas precisam de sua própria fronteira transacional, e o lugar dela é a camada de serviço:
@Service
public class OrderService {
private final OrderRepository orders;
private final InventoryClient inventory;
@Transactional
public Order ship(long orderId) {
Order order = orders.findWithLines(orderId).orElseThrow(() -> new OrderNotFoundException(orderId));
order.markShipped(); // o dirty checking grava isso no commit; não precisa chamar save()
inventory.release(order); // chamada remota dentro de uma transação: mantenha-a curta ou mova-a para depois do commit
return order;
}
@Transactional(readOnly = true)
public Page<OrderSummary> search(OrderStatus status, Pageable pageable) {
return orders.findByStatus(status, pageable);
}
}
Dentro de uma transação, o contexto de persistência acompanha cada entidade carregada; as alterações são detectadas no flush e gravadas como comandos UPDATE, então chamar save() em uma entidade já gerenciada é redundante. readOnly = true coloca o flush mode do Hibernate em MANUAL, dispensa os snapshots do dirty checking e permite que alguns drivers e roteadores enviem a consulta para uma réplica; use-o em todos os caminhos de leitura.
Regras que economizam tempo de depuração: @Transactional só funciona em métodos públicos chamados através do proxy do Spring, então uma autoinvocação dentro da mesma classe não é transacional; exceções runtime fazem rollback por padrão, e exceções checked não (rollbackFor muda isso); e um método @Transactional que captura uma exceção de uma chamada aninhada ao repositório ainda faz rollback, porque a falha interna marcou a transação como rollback-only. O guia do Spring Framework sobre JDBC e transações aborda propagação e isolamento em profundidade.
O problema do N+1
Carregue vinte pedidos e depois acesse order.getLines() em cada um: uma consulta para os pedidos mais vinte para as linhas. O Spring Data e o Hibernate não têm como saber de antemão quais associações quem chama vai acessar, então a solução é dizer a eles:
- Fetch join em JPQL:
select o from Order o join fetch o.lines where .... Buscar duas coleções na mesma consulta multiplica as linhas (um produto cartesiano); o Hibernate 6 remove as duplicatas, mas o número de linhas transferidas continua crescendo, então busque uma coleção por consulta. @EntityGraphem um método do repositório:@EntityGraph(attributePaths = {"lines", "customer"})vale para consultas derivadas e parafindById.- Batch fetching com
hibernate.default_batch_fetch_size: coleções lazy de entidades já carregadas são inicializadas em lotes com uma consultain (...)em vez de uma consulta para cada uma. É a melhoria global mais barata e funciona em qualquer lugar. - Projeções que nunca chegam a carregar a coleção.
Detecte o problema antes dos usuários: registre o SQL nos testes @DataJpaTest e conte os comandos, ou use as estatísticas do Hibernate (spring.jpa.properties.hibernate.generate_statistics=true) em um ambiente de staging e acompanhe a quantidade de consultas por requisição.
Migrações de schema com Flyway
O Boot configura o Flyway automaticamente quando flyway-core está no classpath junto com o módulo específico do banco (flyway-database-postgresql para PostgreSQL desde o Flyway 10). As migrações são arquivos SQL em src/main/resources/db/migration nomeados por versão:
-- V3__add_order_version_and_status_index.sql
alter table orders add column version bigint not null default 0;
create index orders_status_created_at_idx on orders (status, created_at desc);
O Flyway executa as migrações pendentes na inicialização, registra cada uma em flyway_schema_history e se recusa a subir se um arquivo já aplicado tiver sido editado (o checksum mudou). Esse comportamento é intencional: migrações são histórico imutável, e uma migração errada se corrige com uma nova migração. Mantenha o ddl-auto do Hibernate em none em todos os ambientes que usam migrações; os dois mecanismos nunca devem cuidar do schema ao mesmo tempo. Definir spring.jpa.hibernate.ddl-auto=validate nos testes é uma rede de segurança útil: o Hibernate verifica se o schema migrado corresponde aos mapeamentos das entidades e quebra o build se eles divergirem.
O Liquibase é a alternativa equivalente, com changelogs em XML, YAML ou SQL e um modelo de rollback mais rico; o Boot o configura automaticamente da mesma forma.
Erros comuns
Confiar no EAGER padrão das associações to-one. Todo findById arrasta junto o cliente, o endereço do cliente e tudo o que estiver pendurado nele. Defina fetch = FetchType.LAZY explicitamente em cada @ManyToOne e @OneToOne.
Usar geração IDENTITY e estranhar a lentidão dos inserts em lote. Mude para SEQUENCE com allocationSize e configure spring.jpa.properties.hibernate.jdbc.batch_size=50 e hibernate.order_inserts=true.
Chamar save() em um loop para uma atualização em massa. Use um update JPQL com @Modifying ou um comando nativo; mil entidades gerenciadas e mil UPDATEs não são um job batch.
Capturar uma exceção dentro de uma transação e seguir em frente. A transação já está marcada como rollback-only; o commit vai falhar com UnexpectedRollbackException. Trate as falhas fora do método transacional ou use REQUIRES_NEW de forma deliberada.
Armazenar enums como ordinais. Inserir uma nova constante no meio do enum remapeia silenciosamente todas as linhas armazenadas. Sempre EnumType.STRING.
Testar contra H2 em modo PostgreSQL e fazer deploy em PostgreSQL. O tratamento de tipos, as sequences, on conflict e JSON são diferentes. Use Testcontainers com o banco de dados real nos testes de repositório, como mostra o guia de testes.
Boas práticas
- Mantenha as entidades na camada de persistência e mapeie-as para records na fronteira do serviço.
- Uma fronteira
@Transactionalpor caso de uso, no serviço;readOnly = trueem todos os caminhos de consulta. - Torne todas as associações lazy e depois busque de forma deliberada com fetch joins, entity graphs ou batch fetching.
- Prefira projeções para listagens e relatórios; carregue agregados completos só para modificá-los.
- Deixe o schema a cargo do Flyway ou do Liquibase e valide os mapeamentos contra ele no CI.
- Dimensione o pool do HikariCP pela capacidade do banco, não pelo número de threads da aplicação, e monitore os tempos de espera do pool pelas métricas do Actuator.
Principais pontos
- O Boot configura HikariCP, Hibernate e transações a partir de poucas propriedades; desligue o open-in-view e mantenha
ddl-autoemnonefora de bancos descartáveis. - Repositórios são interfaces: consultas derivadas para os casos simples, JPQL com
@Querypara o resto e SQL nativo só quando o banco oferece algo que a JPQL não consegue. - As transações ficam nos métodos de serviço; os métodos do repositório já são transacionais individualmente, e o dirty checking grava as alterações sem chamadas explícitas a
save(). - N+1 selects vêm de associações lazy acessadas fora de um plano de busca; corrija-os com fetch joins,
@EntityGraph, batch fetching ou projeções. - Use por padrão ids
SEQUENCE,@Versionpara lock otimista,EnumType.STRINGe associações to-one lazy. - Migrações Flyway versionadas, validadas contra o modelo de entidades nos testes, mantêm o schema e o código em sincronia.
Fontes e leituras complementares
Perguntas frequentes
- Preciso de @Transactional nas chamadas ao repositório?
- Não para chamadas isoladas: a implementação de repositórios do Spring Data já envolve cada método em uma transação (somente leitura nas consultas). Você precisa, sim, de @Transactional em um método de serviço que encadeia várias chamadas ao repositório e precisa que todas deem certo ou falhem juntas, ou que modifica uma entidade e conta com o dirty checking para gravar a alteração no commit.
- Por que minha associação lazy lança LazyInitializationException?
- A entidade foi carregada dentro de uma transação, a transação terminou e fechou o contexto de persistência, e depois algum código acessou uma coleção ou um proxy não inicializado. Carregue a associação na consulta (join fetch ou @EntityGraph), mapeie para um DTO dentro da transação ou amplie a transação para cobrir o acesso. Habilitar o open-in-view esconde o sintoma, mas não as consultas extras.
- spring.jpa.hibernate.ddl-auto=update é seguro em produção?
- Não. Ele só sabe adicionar, nunca renomear ou remover com segurança; adivinha os tipos das colunas; executa na inicialização DDL que ninguém revisou, com os privilégios da aplicação; e não deixa nenhum histórico para fazer rollback. Use no máximo em bancos locais descartáveis e gerencie os schemas de verdade com migrações versionadas do Flyway ou do Liquibase.
- Quando usar uma consulta nativa em vez de JPQL?
- Quando você precisa de algo que a JPQL não consegue expressar ou que o banco faz muito melhor: window functions, CTEs recursivas, operadores JSON específicos de um fornecedor ou um join ajustado à mão. Consultas nativas perdem a portabilidade e a validação na inicialização contra o modelo de entidades, então use poucas, teste-as contra o banco de dados real e mapeie os resultados para uma projeção ou um DTO.