Pular para o conteúdo

Spring Data

Spring Data JPA abstrai o acesso a banco de dados, eliminando a maior parte do código SQL manual. Com ele, você define entidades e repositórios, e o framework cuida da persistência.

No pom.xml:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Para usar PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Para usar H2 (banco em memória para desenvolvimento) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

JPA (Jakarta Persistence API) é a especificação Java para mapeamento objeto-relacional. Hibernate é a implementação mais popular dessa especificação - é o que Spring Data usa por baixo.

O objetivo é mapear classes Java para tabelas de banco de dados e gerenciar as operações automaticamente.

Uma entidade é uma classe Java mapeada para uma tabela do banco:

import jakarta.persistence.*;
@Entity
@Table(name = "usuarios")
public class Usuario {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String nome;
@Column(nullable = false, unique = true, length = 150)
private String email;
@Column(name = "data_nascimento")
private LocalDate dataNascimento;
@Column(nullable = false)
private boolean ativo = true;
@CreationTimestamp
@Column(name = "criado_em", updatable = false)
private LocalDateTime criadoEm;
// construtores, getters, setters...
}
AnotaçãoFunção
@EntityMarca a classe como entidade JPA
@Table(name = "...")Define o nome da tabela (padrão: nome da classe)
@IdChave primária
@GeneratedValueEstratégia de geração do ID
@ColumnPersonaliza a coluna (nome, tamanho, nullable)
@TransientCampo ignorado pelo JPA (não persiste)
@CreationTimestampPreenchido automaticamente no INSERT
@UpdateTimestampPreenchido automaticamente no UPDATE

Uma entidade precisa ser uma classe mutável de verdade, com construtor sem argumentos: um record (visto em Java Moderno) não funciona como entidade JPA, porque a especificação exige exatamente o que um record proíbe por definição (campos não-final, classe não-final, construtor vazio). Records continuam sendo a escolha certa para DTO e projeção de leitura desse mesmo dado, só não para a entidade gerenciada pelo Hibernate.

No código acima o campo status é um enum (StatusPedido), mas a coluna no banco é um número ou um texto. Quem decide qual dos dois é a anotação @Enumerated, e o default dela é justamente a opção que mais dá dor de cabeça.

Sem argumento nenhum, @Enumerated equivale a @Enumerated(EnumType.ORDINAL): o Hibernate grava a posição da constante no enum, o valor de ordinal().

public enum StatusPedido {
NOVO, // 0
PAGO, // 1
ENVIADO, // 2
ENTREGUE // 3
}
@Enumerated // ORDINAL implícito
private StatusPedido status;

Um pedido PAGO vira o número 1 na coluna. Funciona, ocupa pouco espaço, e é uma bomba-relógio. No dia em que alguém precisar de um status AGUARDANDO_PAGAMENTO entre NOVO e PAGO:

public enum StatusPedido {
NOVO, // 0
AGUARDANDO_PAGAMENTO, // 1 <- novo
PAGO, // 2 (era 1)
ENVIADO, // 3 (era 2)
ENTREGUE // 4 (era 3)
}

Todas as linhas que tinham 1 continuam com 1, só que agora 1 significa AGUARDANDO_PAGAMENTO. Cada pedido pago virou “aguardando pagamento” de uma vez, sem erro e sem log. O banco não faz ideia de que o significado dos números mudou.

@Enumerated(EnumType.STRING) resolve isso gravando o nome da constante:

@Enumerated(EnumType.STRING)
@Column(length = 20)
private StatusPedido status;

Agora a coluna guarda o texto PAGO. Você pode reordenar o enum, inserir constantes no meio ou remover as que não usa mais, e as linhas antigas continuam apontando para a constante certa. O preço é modesto: a coluna vira um varchar em vez de um smallint, e renomear uma constante (PAGO para PAGAMENTO_CONFIRMADO) passa a exigir um UPDATE para acertar os dados que já estão gravados.

Quando você quer um código curto e estável na coluna, desacoplado do nome da constante Java, dá para assumir o controle total com um AttributeConverter:

@Converter(autoApply = true)
public class StatusPedidoConverter implements AttributeConverter<StatusPedido, String> {
@Override
public String convertToDatabaseColumn(StatusPedido status) {
return status == null ? null : status.getCodigo(); // "N", "P", "E"...
}
@Override
public StatusPedido convertToEntityAttribute(String codigo) {
return StatusPedido.peloCodigo(codigo);
}
}

Assim o nome da constante e o valor no banco evoluem separados: renomear o enum não toca no banco, e mudar o código gravado não toca no enum.

Regra prática: em toda entidade nova, use EnumType.STRING. Nunca deixe o @Enumerated no default. Se precisar economizar espaço ou já herdou uma coluna com códigos, parta para o AttributeConverter em vez de voltar para ORDINAL.

A maior parte da confusão com JPA some quando você entende que uma entidade não está sempre “conectada” ao banco. Ela passa por estados, e o comportamento do Hibernate muda em cada um.

O centro de tudo é o contexto de persistência (persistence context), também chamado de cache de primeiro nível. Durante uma transação, o Hibernate mantém ali dentro uma cópia de cada entidade que ele está gerenciando, com a garantia de que existe uma única instância por identidade: se você buscar o Usuario de id 7 duas vezes na mesma transação, recebe o mesmo objeto Java, não duas cópias.

stateDiagram-v2
    [*] --> Transient: new
    Transient --> Managed: persist()
    Managed --> Detached: fim da transação / detach() / clear()
    Detached --> Managed: merge()
    Managed --> Removed: remove()
    Removed --> [*]: DELETE no flush

Transient (a especificação JPA chama de new): um objeto que você criou com new e mais nada. Não tem linha correspondente no banco, não está no contexto de persistência, e o Hibernate não sabe que ele existe. Mudar os campos dele não gera SQL nenhum.

Usuario u = new Usuario("Ana", "ana@exemplo.com"); // transient

Managed (ou persistent): a entidade está no contexto de persistência e é um espelho de uma linha da tabela. Todo campo que você alterar é detectado e sincronizado com o banco automaticamente. Uma entidade fica managed depois de um persist(), ou quando você a carrega com findById, uma query, etc.

Usuario u = repository.findById(7L).orElseThrow(); // managed
u.setNome("Ana Paula"); // sem chamar save, o UPDATE vai sair no fim da transação

Detached: a entidade já teve (ou tem) uma linha no banco, mas não está mais sendo acompanhada por nenhum contexto de persistência, porque a transação terminou, ou você chamou detach()/clear(). O objeto continua na memória com os dados que tinha, mas alterá-lo não afeta o banco.

Removed: a entidade foi marcada para exclusão com remove() (ou o delete do repositório). Ela ainda está no contexto, mas o Hibernate já agendou um DELETE para o próximo flush.

DeParaComo
TransientManagedentityManager.persist(e) (ou repository.save(e) num objeto novo)
ManagedDetachedfim da transação, detach(e), clear(), ou close() do EntityManager
DetachedManagedentityManager.merge(e)
ManagedRemovedentityManager.remove(e) (ou repository.delete(e))

Um detalhe que pega muita gente: merge() não transforma o objeto que você passou em managed. Ele copia os dados desse objeto para uma instância managed (buscando no banco se preciso) e devolve essa outra instância. Depois de Usuario gerenciado = em.merge(destacado), quem está managed é gerenciado, não destacado. Continuar mexendo em destacado não faz nada.

O motivo de uma entidade managed não precisar de save() para persistir mudanças é o dirty checking. Quando a entidade entra no contexto, o Hibernate guarda um retrato (snapshot) do estado dela. No flush (quando ele envia o SQL pendente ao banco), ele compara o estado atual campo a campo com esse snapshot e, para cada entidade com pelo menos um campo diferente, gera um UPDATE.

Isso tem um custo: em toda entidade carregada, o Hibernate carrega o dobro de dados na memória (o objeto e o snapshot) e faz a comparação no flush. É por isso que uma consulta só de leitura se beneficia de projeções (o resultado de uma projeção não entra no contexto, então não tem snapshot nem dirty checking) e de @Transactional(readOnly = true). Esse assunto está na seção Projeções mais abaixo.

Não são a mesma coisa. O flush é o momento em que o Hibernate traduz as mudanças pendentes em SQL e manda para o banco. O commit é o momento em que a transação é confirmada e essas mudanças ficam definitivas.

O Hibernate faz flush automaticamente antes do commit, e às vezes antes de uma query (para o resultado refletir o que você já alterou). Então o SQL pode “sair” antes do fim do método @Transactional, mas ainda dá para reverter tudo com um rollback até o commit acontecer. O @Transactional que fecha esse ciclo está na seção do fim da nota.

Relacionamento 1:1: cada Usuario tem no máximo um Perfil, e vice-versa. A forma mais simples é unidirecional, com a chave estrangeira do lado que faz mais sentido “possuir” a referência:

@Entity
public class Perfil {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
@OneToOne
@JoinColumn(name = "usuario_id", unique = true)
private Usuario usuario;
}

Para navegar dos dois lados (usuario.getPerfil() e perfil.getUsuario()), o relacionamento vira bidirecional. Só um dos lados pode ter a coluna de chave estrangeira (o lado dono, marcado com @JoinColumn); o outro lado só declara mappedBy, apontando o nome do campo dono:

@Entity
public class Usuario {
// ...
@OneToOne(mappedBy = "usuario", cascade = CascadeType.ALL)
private Perfil perfil;
}

Uma FK separada com unique = true funciona, mas duplica a garantia de unicidade que já existe na chave primária da outra tabela. Quando as duas entidades sempre nascem e morrem juntas (não existe Perfil sem Usuario), o @MapsId é a opção mais enxuta: a tabela perfil usa o mesmo valor de usuario_id como sua própria chave primária, em vez de ter um id autoincrementado e uma coluna de FK à parte.

@Entity
public class Perfil {
@Id
private Long id; // sem @GeneratedValue: o valor vem do usuário associado
private String bio;
@OneToOne
@MapsId
@JoinColumn(name = "usuario_id")
private Usuario usuario;
}

Antes de modelar como @OneToOne, vale perguntar se as duas classes realmente precisam ser entidades separadas. Se Perfil não tem ciclo de vida próprio nem é consultado sozinho, colocar bio e avatarUrl como colunas direto na entidade Usuario é mais simples e evita um JOIN a mais em toda consulta.

@Entity
public class Pedido {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Muitos pedidos para um usuário
@ManyToOne(optional = false)
@JoinColumn(name = "usuario_id")
private Usuario usuario;
@Column(nullable = false)
private BigDecimal valor;
@Enumerated(EnumType.STRING)
private StatusPedido status;
}
// No Usuario (opcional - mapeamento bidirecional)
@Entity
public class Usuario {
// ...
@OneToMany(mappedBy = "usuario", cascade = CascadeType.ALL)
private List<Pedido> pedidos = new ArrayList<>();
}
@Entity
public class Produto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "produto_categoria",
joinColumns = @JoinColumn(name = "produto_id"),
inverseJoinColumns = @JoinColumn(name = "categoria_id")
)
private List<Categoria> categorias = new ArrayList<>();
}

@GeneratedValue(strategy = GenerationType.IDENTITY) (delegando para um AUTO_INCREMENT/SERIAL do banco) é a opção mais comum para começar, mas em sistemas distribuídos ou de alto volume, a escolha do tipo de ID afeta performance de um jeito que só aparece depois que a tabela já cresceu.

UUID.randomUUID() (a versão 4 do UUID) é totalmente aleatório, e isso é justamente o problema para uma chave primária indexada. Bancos relacionais organizam índices numa estrutura de árvore (B-tree), pensada para inserções que chegam em sequência crescente. Um UUID aleatório cai num ponto imprevisível da árvore a cada inserção, o que aumenta a fragmentação do índice e o custo de I/O conforme a tabela cresce.

@Id
private UUID id = UUID.randomUUID(); // funciona, mas fragmenta o índice ao longo do tempo

Isso não significa que a alternativa seja voltar para um ID sequencial simples. Sequências puras trazem problemas próprios: expõem informação do sistema (criar um registro no primeiro e no último dia do mês permite inferir quantos registros existem no período), e em arquitetura distribuída dependem de um contador centralizado, o que cria contenção e dificulta escalar horizontalmente sem coordenação entre instâncias.

O meio-termo mais usado hoje é UUIDv7 ou ULID, que incorporam um componente de tempo nos bits mais significativos do identificador, o que os torna aproximadamente ordenados por ordem de criação, e por isso muito mais amigáveis para índice B-tree e para particionamento por faixa (sharding) do que o UUIDv4 puro. UUIDv7 tem a vantagem de preservar o formato padrão de UUID, funcionando como substituto direto de UUID.randomUUID() sem mudar o tipo da coluna nem quebrar nenhum contrato existente.

Regra prática: se a tabela é pequena ou local a um único banco, IDENTITY continua sendo a opção mais simples. Se o sistema é distribuído ou de alto volume e você precisa gerar o ID antes de persistir (fora do banco), prefira UUIDv7 a UUIDv4 aleatório.

Spring Data gera a implementação automaticamente. Você só define a interface:

Operações básicas de CRUD:

import org.springframework.data.repository.CrudRepository;
public interface UsuarioRepository extends CrudRepository<Usuario, Long> {
// findAll, findById, save, deleteById - já herdados
}

Estende CrudRepository com funcionalidades extras (paginação, ordenação):

import org.springframework.data.jpa.repository.JpaRepository;
public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
// Todos os métodos do CrudRepository + findAll(Pageable) etc.
}

JpaRepository<Entidade, TipoDoId> - use este na maioria dos casos.

Spring Data deriva queries automaticamente a partir do nome do método:

public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
// SELECT * FROM usuarios WHERE email = ?
Optional<Usuario> findByEmail(String email);
// SELECT * FROM usuarios WHERE nome LIKE '%?%'
List<Usuario> findByNomeContaining(String nome);
// SELECT * FROM usuarios WHERE ativo = true ORDER BY nome
List<Usuario> findByAtivoTrueOrderByNome();
// SELECT * FROM usuarios WHERE ativo = ? AND email LIKE ?
List<Usuario> findByAtivoAndEmailContaining(boolean ativo, String email);
// Existência
boolean existsByEmail(String email);
// Contagem
long countByAtivo(boolean ativo);
// Deletar por critério
void deleteByAtivoFalse();
}
// JPQL - usa nomes de classes e atributos Java, não SQL
@Query("SELECT u FROM Usuario u WHERE u.email = :email AND u.ativo = true")
Optional<Usuario> buscarAtivoPorEmail(@Param("email") String email);
// SQL nativo
@Query(value = "SELECT * FROM usuarios WHERE YEAR(data_nascimento) = :ano",
nativeQuery = true)
List<Usuario> buscarPorAnoNascimento(@Param("ano") int ano);
// Update/Delete via @Modifying
@Modifying
@Query("UPDATE Usuario u SET u.ativo = false WHERE u.id = :id")
void desativar(@Param("id") Long id);
import org.springframework.data.domain.*;
// No repositório
Page<Usuario> findByAtivo(boolean ativo, Pageable pageable);
// No service/controller
Pageable pageable = PageRequest.of(0, 20, Sort.by("nome").ascending());
Page<Usuario> pagina = repository.findByAtivo(true, pageable);
System.out.println(pagina.getContent()); // lista da página
System.out.println(pagina.getTotalElements()); // total de registros
System.out.println(pagina.getTotalPages()); // total de páginas

Imagine uma tela que lista usuários e mostra só nome e email. O jeito óbvio é findByAtivoTrue(), que devolve List<Usuario>. Só que a entidade Usuario tem uns 15 campos, talvez um relacionamento com Endereco, outro com Pedido. O banco carrega tudo isso, o Hibernate monta os objetos, e você usa dois campos.

Numa lista de 50 linhas isso passa despercebido. Numa de milhares, ou com relacionamentos que puxam mais tabelas junto, a diferença aparece: consultas que levam segundos para montar dados que ninguém vai olhar.

O custo extra de carregar a entidade completa numa leitura tem três partes:

  • Dirty checking: toda entidade carregada entra no contexto de persistência, e o Hibernate guarda um snapshot dela para, no fim da transação, comparar campo a campo e ver o que mudou. Numa consulta só de leitura, esse trabalho é jogado fora.
  • Memória: o objeto mais o snapshot, multiplicados pela quantidade de linhas.
  • SELECT gordo: todas as colunas da tabela, mais os joins dos relacionamentos que forem EAGER.

Projeção é pedir ao Spring Data para trazer só um subconjunto de campos, num objeto que não é a entidade. Existem três formatos.

Um record com os campos que você quer, e um método no repositório que retorna esse tipo:

public record ResumoUsuario(String nome, String email) {}
public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
List<ResumoUsuario> findByAtivoTrue();
}

O Spring Data olha o record, vê que os nomes dos componentes (nome, email) batem com atributos da entidade, e gera um SELECT u.nome, u.email FROM Usuario u WHERE u.ativo = true. O resultado é uma lista de ResumoUsuario, e nenhum desses objetos entra no contexto de persistência: sem snapshot, sem dirty checking, sem flush comparando estado.

Para juntar dados de mais de uma tabela, use @Query com uma expressão de construtor JPQL (começa com new e o nome completo da classe):

@Query("""
SELECT new com.exemplo.dto.ResumoPedido(p.numero, u.nome, p.valorTotal, p.criadoEm)
FROM Pedido p JOIN p.usuario u
WHERE p.status = :status
""")
List<ResumoPedido> resumoPorStatus(@Param("status") StatusPedido status);

Isso também funciona em query nativa, com nativeQuery = true, desde que os nomes das colunas retornadas batam com o construtor.

Em vez de um record, uma interface só com os getters:

public interface ResumoUsuario {
String getNome();
String getEmail();
}
List<ResumoUsuario> findByAtivoTrue();

O Spring Data cria um proxy em tempo de execução que implementa a interface. É a opção mais enxuta quando você não precisa de lógica nenhuma no objeto de saída, só ler os campos.

Quando o mesmo método precisa às vezes devolver a entidade e às vezes um resumo, dá para deixar o tipo aberto:

<T> List<T> findByAtivoTrue(Class<T> tipo);
repository.findByAtivoTrue(Usuario.class); // entidade completa
repository.findByAtivoTrue(ResumoUsuario.class); // só o resumo

Projeção é para leitura. Se o fluxo vai alterar e salvar, você precisa da entidade gerenciada, porque é o dirty checking (aquele mesmo que era desperdício na leitura) que detecta a mudança e gera o UPDATE. A regra prática: consulta que só exibe dados pede projeção; consulta que carrega algo para modificar pede a entidade.

Vale notar a simetria com o DTO de entrada visto em Validação, DTO e Logging: lá, um objeto separado protege a entidade dos dados que chegam na requisição; aqui, um objeto separado evita expor e carregar a entidade inteira na resposta. Mesma ideia, pontas opostas do fluxo.

H2 é um banco relacional em memória, ideal para desenvolvimento e testes. Não precisa de instalação.

application.properties
spring.datasource.url=jdbc:h2:mem:devdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Console web do H2 (acessível em /h2-console)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

Acesse http://localhost:8080/h2-console para inspecionar o banco durante o desenvolvimento.

spring.datasource.url=jdbc:postgresql://localhost:5432/meudb
spring.datasource.username=postgres
spring.datasource.password=senha
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
# Mostrar SQL gerado no log (útil em dev)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# DDL: create, create-drop, update, validate, none
spring.jpa.hibernate.ddl-auto=validate

Em produção, use validate ou none e gerencie o schema com Flyway ou Liquibase.

@Entity
@Table(name = "produtos")
public class Produto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String nome;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal preco;
@Column(nullable = false)
private int estoque;
// getters e setters
}
@Repository
public interface ProdutoRepository extends JpaRepository<Produto, Long> {
List<Produto> findByNomeContainingIgnoreCase(String nome);
List<Produto> findByEstoqueGreaterThan(int quantidade);
}
@Service
@Transactional
public class ProdutoService {
private final ProdutoRepository repository;
public ProdutoService(ProdutoRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<Produto> listar() {
return repository.findAll();
}
@Transactional(readOnly = true)
public Produto buscar(Long id) {
return repository.findById(id)
.orElseThrow(() -> new RecursoNaoEncontradoException("Produto não encontrado"));
}
public Produto criar(Produto produto) {
return repository.save(produto);
}
public Produto atualizar(Long id, Produto dados) {
Produto produto = buscar(id);
produto.setNome(dados.getNome());
produto.setPreco(dados.getPreco());
produto.setEstoque(dados.getEstoque());
return repository.save(produto);
}
public void deletar(Long id) {
buscar(id); // verifica se existe
repository.deleteById(id);
}
}
@RestController
@RequestMapping("/produtos")
public class ProdutoController {
private final ProdutoService service;
public ProdutoController(ProdutoService service) {
this.service = service;
}
@GetMapping
public List<Produto> listar() {
return service.listar();
}
@GetMapping("/{id}")
public Produto buscar(@PathVariable Long id) {
return service.buscar(id);
}
@PostMapping
public ResponseEntity<Produto> criar(@RequestBody Produto produto) {
Produto salvo = service.criar(produto);
return ResponseEntity.status(201).body(salvo);
}
@PutMapping("/{id}")
public Produto atualizar(@PathVariable Long id, @RequestBody Produto produto) {
return service.atualizar(id, produto);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletar(@PathVariable Long id) {
service.deletar(id);
return ResponseEntity.noContent().build();
}
}

Garante que operações de banco aconteçam dentro de uma transação:

@Transactional
public void transferir(Long origemId, Long destinoId, BigDecimal valor) {
Conta origem = buscar(origemId);
Conta destino = buscar(destinoId);
origem.debitar(valor);
destino.creditar(valor);
repository.save(origem);
repository.save(destino);
// Se qualquer linha acima lançar exceção, tudo é revertido (rollback)
}

Use @Transactional(readOnly = true) em métodos de apenas leitura - é uma dica de otimização para o banco.