Pular para o conteúdo

Trilha de Kotlin · parte 47 de 59

JPA básico: entidades, repositórios e @Transactional

Como mapear classes Kotlin para tabelas com JPA e Spring Data, e por que o rollback depende da classe da exceção, com saídas medidas em PostgreSQL.

intermediárioPublicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine um cartório que trabalha com rascunhos. Você copia a ficha para a sua mesa, risca o que mudou, e ninguém avisa o arquivo a cada risco. No fim do expediente, o cartório grava tudo de uma vez. Se algo deu errado, o rascunho vai para o lixo e o arquivo continua como estava. A analogia falha no tipo de erro: o cartório não distingue, e o Spring distingue.

Esta aula apresenta o JPA, a camada que faz esse rascunho entre classes Kotlin e tabelas. Ela supõe o que a aula de SQL e modelagem relacional essencial ensina: tabelas, chaves, JOIN e transação em linhas gerais. O domínio é o mesmo, clientes e pedidos.

o mapa da aula

O mapa segue a ordem do JPA: problema, entidade, repositório, transação, dirty checking, esquema, erros e exercícios.

Versões: Spring Boot 4.1.2, Spring Framework 7.0.10, Spring Data JPA 4.1.2, Hibernate ORM 7.4.13.Final, Kotlin 2.4.21, Gradle 9.7.0 e PostgreSQL 18.6. As aulas de JPA anteriores citam outras versões, e a divergência está no fim.

1. o problema que o JPA resolve

Um pedido tem um cliente. No banco, isso é uma chave estrangeira em outra tabela. No código, é uma referência para outro objeto. A diferença entre os dois modelos tem nome: descompasso objeto-relacional.

Com JDBC puro, você escreve o SQL, preenche os parâmetros pela posição e copia cada coluna para o objeto à mão. Para salvar um pedido, o Hibernate gera este SQL, que é o que você escreveria:

insert into pedido (cliente_id,status,valor_total,id) values (?,?,?,?)

Se a tabela ganha uma coluna e o código não acompanha, o erro aparece na execução, longe da mudança. O JPA descreve o mapeamento uma vez, na classe, e gera o SQL a partir dele.

JPA é a especificação, a API do pacote jakarta.persistence. Hibernate é uma implementação dela, como a documentação do projeto afirma.

2. entidade: a classe que vira linha

Uma entidade é uma ficha de cadastro com número fixo: os dados mudam, o número continua identificando a ficha. Estas são as classes do experimento:

@Entity
@Table(name = "cliente")
class Cliente(
    @Id @GeneratedValue var id: Long? = null,
    @Column(nullable = false, length = 80) var nome: String = "",
    @OneToMany(mappedBy = "cliente") var pedidos: MutableList<Pedido> = mutableListOf(),
)

enum class StatusPedido { ABERTO, ENTREGUE, CANCELADO }

@Entity
@Table(name = "pedido")
class Pedido(
    @Id @GeneratedValue var id: Long? = null,
    @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) var status: StatusPedido = StatusPedido.ABERTO,
    @Column(name = "valor_total", nullable = false, precision = 10, scale = 2) var valorTotal: BigDecimal = BigDecimal.ZERO,
    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cliente_id", nullable = false) var cliente: Cliente? = null,
)

@Id marca a chave, e @GeneratedValue pede um valor ao banco. No PostgreSQL, a estratégia padrão virou sequências, que aparecem no esquema da seção 6. @Column define o detalhe da coluna: nullable = false vira not null, e precision = 10, scale = 2 vira numeric(10,2). @Enumerated(EnumType.STRING) grava o nome do enum, porque a posição quebra se alguém reordenar os valores.

O @ManyToOne cria cliente_id, a coluna com a chave estrangeira. O @OneToMany(mappedBy = "cliente") não cria coluna: a ligação mora do lado “muitos”, e o mappedBy aponta para ela. O fetch = FetchType.LAZY fica para a aula de JPA, Hibernate e N+1.

regras de classe no Kotlin

Kotlin cria classes finais por padrão, e o JPA precisa de um construtor sem argumentos e de classes abertas para os proxies do carregamento lazy. O plugin kotlin-jpa faz as duas coisas para @Entity, como a documentação do Kotlin descreve. No bytecode desta compilação, Cliente aparece como public class aula72.Cliente, sem final, com public aula72.Cliente(); gerado.

O kotlin-spring faz o mesmo para as anotações do Spring, e a aula de Injeção de dependência e IoC no Spring explica o plugin. Para testar sem ele, tirei a linha do build e deixei o PedidoService final, com @Transactional. A subida parou com:

Caused by: java.lang.IllegalArgumentException: Cannot subclass final class aula72.PedidoService

Sem o plugin, a aplicação não sobe.

Por que class e não data class? A data class gera equals, hashCode e toString com todas as propriedades, o que mexe com a identidade e com os relacionamentos. O caso completo está em data class como entidade.

3. repositório: o balcão do arquivo

Um repositório é o balcão de um arquivo: você pede o documento pelo nome e não entra no depósito. A interface abaixo não tem implementação escrita; o Spring Data cria uma na inicialização.

interface ClienteRepository : JpaRepository<Cliente, Long>

interface PedidoRepository : JpaRepository<Pedido, Long> {
    fun findByStatus(status: StatusPedido): List<Pedido>

    @Query("select p from Pedido p where p.valorTotal >= :minimo order by p.valorTotal desc")
    fun caros(minimo: BigDecimal): List<Pedido>
}

JpaRepository<Pedido, Long> herda save, findById, findAll, count e deleteById. deleteById também vem dessa interface.

Um método find...By vira consulta pelo nome: findByStatus gerou where p1_0.status=?. Quando o nome não basta, @Query aceita JPQL, que fala de propriedades, e o Hibernate traduz para colunas. O log:

Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.status=?
findByStatus(ABERTO): [30.00]
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.valor_total>=? order by p1_0.valor_total desc
caros(50.00): [80.00]

4. @Transactional: o que é uma transação aqui

Uma transação é a conta de um restaurante: o pedido inteiro é cobrado ou nada entra. A analogia falha no motivo do erro, que a conta ignora e o Spring observa.

No JPA, a transação junta as alterações da sessão e as envia ao banco no commit. No rollback, elas são descartadas. O @Transactional abre a transação antes do método e a fecha depois, e costuma ficar na camada de serviço. No modo proxy, como diz a documentação do Spring, só as chamadas de fora do objeto são interceptadas. Isso causa o erro da seção 7.

Fluxo de um método @Transactional: o proxy abre a transação, o serviço altera o pedido na sessão, e o commit envia o UPDATE ou o rollback descarta tudo
figura 1 · Transação e sessão em um método @Transactional

O proxy abre a transação, o serviço altera a entidade gerenciada, e o flush do commit envia o UPDATE. Se o método lança RuntimeException, o caminho da direita leva ao rollback.

O rollback também segue uma regra do Spring: ele reverte para RuntimeException e Error, e exceções verificadas não revertem, a menos que o rollbackFor diga outra coisa. O Kotlin não tem exceções verificadas, mas o Spring olha a classe da exceção, não o compilador.

Decisão do Spring ao sair de um método: RuntimeException ou Error reverte, e outra exceção só reverte se o rollbackFor a listar, caso contrário há commit
figura 2 · Decisão de rollback por tipo de exceção

Exceções verificadas, como IOException, passam pelo segundo losango: sem rollbackFor, há commit.

O serviço tem os casos medidos:

@Service
class PedidoService(
    private val clientes: ClienteRepository,
    private val pedidos: PedidoRepository,
) {
    private fun gravarPedido(clienteId: Long): Long {
        val cliente = clientes.findById(clienteId).orElseThrow()
        return pedidos.save(Pedido(valorTotal = BigDecimal("10.00"), cliente = cliente)).id!!
    }

    @Transactional
    fun falharComRuntime(clienteId: Long) {
        gravarPedido(clienteId)
        throw IllegalStateException("falha de negócio")
    }

    @Transactional
    fun falharComChecada(clienteId: Long) {
        gravarPedido(clienteId)
        throw IOException("falha de I/O")
    }

    @Transactional(rollbackFor = [IOException::class])
    fun falharComChecadaComRegra(clienteId: Long) {
        gravarPedido(clienteId)
        throw IOException("falha de I/O com regra")
    }

    fun falharPorDentro(clienteId: Long) {
        falharComRuntime(clienteId)
    }

    @Transactional
    fun marcarEntregue(pedidoId: Long) {
        val pedido = pedidos.findById(pedidoId).orElseThrow()
        pedido.status = StatusPedido.ENTREGUE
    }

    fun marcarCanceladoSemTransacao(pedidoId: Long) {
        val pedido = pedidos.findById(pedidoId).orElseThrow()
        pedido.status = StatusPedido.CANCELADO
    }
}

O main conta os pedidos antes e depois de cada caso:

=== 3. @Transactional e rollback
Hibernate: select count(*) from pedido p1_0
Hibernate: select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
lançada: falha de negócio
Hibernate: select count(*) from pedido p1_0
RuntimeException: pedidos antes=2 depois=2
Hibernate: select count(*) from pedido p1_0
Hibernate: select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
Hibernate: insert into pedido (cliente_id,status,valor_total,id) values (?,?,?,?)
lançada: falha de I/O
Hibernate: select count(*) from pedido p1_0
IOException sem regra: pedidos antes=2 depois=3
Hibernate: select count(*) from pedido p1_0
Hibernate: select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
lançada: falha de I/O com regra
Hibernate: select count(*) from pedido p1_0
IOException com rollbackFor: pedidos antes=3 depois=3
Hibernate: select count(*) from pedido p1_0
Hibernate: select c1_0.id,c1_0.nome from cliente c1_0 where c1_0.id=?
Hibernate: insert into pedido (cliente_id,status,valor_total,id) values (?,?,?,?)
lançada: falha de negócio
Hibernate: select count(*) from pedido p1_0
chamada interna: pedidos antes=3 depois=4

No primeiro caso, o insert nem aparece: o Hibernate o envia no flush, e a exceção veio antes do commit. No segundo, IOException sem regra, o commit grava a linha. No terceiro, o rollbackFor reverte. No quarto, falharPorDentro chama falharComRuntime com this, sem o proxy, e o save grava na sua própria transação.

5. dirty checking: a mudança sem save

Dentro da transação, a entidade devolvida por findById é gerenciada pela sessão. Basta mudar uma propriedade: no flush, o Hibernate compara o estado com o que carregou e escreve o UPDATE. O guia do Hibernate trata disso, mas li só o sumário dele.

marcarEntregue altera o status sem save. marcarCanceladoSemTransacao faz a mesma alteração sem @Transactional. O log dos dois:

=== 4. dirty checking dentro da transação
Hibernate: insert into pedido (cliente_id,status,valor_total,id) values (?,?,?,?)
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.id=?
Hibernate: update pedido set cliente_id=?,status=?,valor_total=? where id=?
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.id=?
status depois: ENTREGUE
=== 5. a mesma alteração sem transação
Hibernate: insert into pedido (cliente_id,status,valor_total,id) values (?,?,?,?)
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.id=?
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.id=?
status depois: ABERTO

O UPDATE aparece só no método transacional. Fora dele, o findById devolve uma entidade desligada da sessão, e a releitura mostra ABERTO. A documentação do Spring explica: sem transação, o EntityManager compartilhado cria um novo para cada operação.

Observação: o UPDATE leva as três colunas, e não só status, com a configuração padrão. O guia cita atualizações dinâmicas, que não testei.

Pergunta de entrevistaPor que uma IOException não faz rollback por padrão no Spring?

Por padrão, o Spring faz rollback para RuntimeException e Error. Uma IOException é uma exceção de checagem em Java, e o Spring não a trata como motivo para reverter, então o commit acontece mesmo com ela. No Kotlin não existe exceção verificada, mas o Spring olha a classe da exceção, não a sintaxe. Por isso declaro rollbackFor quando lanço uma exceção desse tipo, ou transformo o erro em RuntimeException.

6. esquema: quem cria as tabelas

No experimento, spring.jpa.hibernate.ddl-auto=create-drop cria as tabelas na subida e as apaga na saída, e assim o banco fica reproduzível. O esquema gerado foi:

Hibernate: create sequence cliente_seq start with 1 increment by 50
Hibernate: create sequence pedido_seq start with 1 increment by 50
Hibernate: create table cliente (id bigint not null, nome varchar(80) not null, primary key (id))
Hibernate: create table pedido (valor_total numeric(10,2) not null, cliente_id bigint not null, id bigint not null, status varchar(20) not null check ((status in ('ABERTO','ENTREGUE','CANCELADO'))), primary key (id))

O Hibernate também gera a chave estrangeira de pedido.cliente_id com um alter table, que o log acima não mostra. Em produção, o esquema vem de migrations, como mostra Migrations com Liquibase. Deixar o Hibernate alterar tabelas de produção a cada subida é uma mudança sem registro.

7. erros comuns e boas práticas

A chamada interna é o erro mais traiçoeiro: um método chama outro @Transactional com this, a anotação não tem efeito e a linha fica. Coloque o ponto de entrada em outro bean.

Exceção do Kotlin não reverte sozinha. Uma IOException é tratada como verificada e gera commit com dados parciais. Use um RuntimeException, ou declare o rollbackFor.

Alterar uma entidade fora de transação não dá erro: o UPDATE simplesmente não existe. A alteração deve ficar dentro do método de serviço. Esquecer o plugin, por sua vez, falha na subida. Inclua kotlin-jpa e kotlin-spring desde o início.

8. exercícios

Exercício guiado: crie findByClienteId(clienteId: Long): List<Pedido> e conte os pedidos da Carla. Dica: o nome atravessa a propriedade cliente até o id.

Resposta:

    // respostas dos exercícios
    fun findByClienteId(clienteId: Long): List<Pedido>

    fun countByStatusAndValorTotalGreaterThan(status: StatusPedido, minimo: BigDecimal): Long

A chamada está no bloco 6 do main, e o log dela é:

=== 6. exercícios: respostas
Hibernate: select p1_0.id,p1_0.cliente_id,p1_0.status,p1_0.valor_total from pedido p1_0 where p1_0.cliente_id=?
findByClienteId: 6 pedidos da Carla
Hibernate: select count(p1_0.id) from pedido p1_0 where p1_0.status=? and p1_0.valor_total>?
countByStatusAndValorTotalGreaterThan: 2

São 6 pedidos: o de 30,00 e o de 80,00 do cadastro, os dois de 10,00 gravados com commit, e os de 15,00 e 20,00 das seções 4 e 5. Os revertidos não entram.

Exercício 2: conte os pedidos ABERTO com valor maior que 10,00. Resposta: countByStatusAndValorTotalGreaterThan, no bloco acima. O resultado foi 2, os de 30,00 e 20,00: o de 15,00 já virou ENTREGUE, os de 10,00 não passam do GreaterThan estrito, e o de 20,00 segue ABERTO, porque o cancelamento não foi gravado. A nota não mostra o status do pedido de 80,00 no fim dos experimentos. Se ele estiver ABERTO, a contagem seria 3, e a resposta depende desse status.

glossário

Entidade: classe mapeada para uma tabela, identificada por @Id.

Sessão de persistência: contexto em que o Hibernate guarda as entidades carregadas e decide quando enviar as alterações.

Transação: período que agrupa as alterações, com commit para gravar e rollback para descartar.

Flush: envio das alterações pendentes da sessão ao banco, feito no commit e antes de algumas consultas.

Dirty checking: detecção, pelo Hibernate, das propriedades de entidades gerenciadas que mudaram.

Proxy: objeto que o Spring cria na frente do bean e que abre e fecha a transação ao interceptar chamadas.

o que não foi confirmado ou medido

Os dados iniciais (clientes e pedidos de 30,00 e 80,00) estão no código de seed, que não está nesta nota. Por isso não confirmo que os seis pedidos são todos da Carla.

O mecanismo das sequências não foi investigado, então não explico o nextval irregular.

A documentação diz que o rollback também reverte para Error, mas não testei. Não testei o lazy de uma entidade final, nem a sessão por requisição web. Não medi custo de transações longas, concorrência nem isolamento.

Os trechos omitem a linha package (pacote aula72) e os imports. As saídas são o log real. Omiti só linhas de insert e de criação do esquema, e o texto diz onde isso acontece.

Divergências: as aulas de JPA anteriores citam Spring Boot 4.1.1, Hibernate 7.4.5 e PostgreSQL 17.11. O Spring Initializr marca 4.1.2 como padrão, e a 4.2.0.RC1 não é estável. O PostgreSQL 19 aparece como RC1 na página oficial, então usei 18.6.

Experimento: Mac com Apple silicon, contra o contêiner postgres:18.6. A saída no JDK 21 e no JDK 25 foi idêntica, conferida com diff.

fontes

Consultadas em 2026-11-04:

Versões e bytecode foram conferidos localmente, com o Gradle e com javap.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?