Trilha de Kotlin · parte 49 de 59
data class como entidade
Aula da fase 7 da trilha de Kotlin: Por que `equals`, `hashCode` e `toString` gerados podem disparar carregamento lazy ou recursão em relacionamentos?
intermediárioPublicada em · 13 min de leitura
Imagine uma gaveta de arquivos em que cada pasta tem uma etiqueta com o conteúdo inteiro escrito por fora. Se você mudar uma folha dentro da pasta sem trocar a etiqueta, a pasta continua no mesmo lugar, mas a etiqueta já não descreve o que está dentro. Quando você procura pela etiqueta nova, não encontra a pasta. Uma data class faz algo parecido com o próprio objeto: a igualdade e o hashCode dependem de todos os campos. Se um desses campos muda enquanto o objeto está guardado num conjunto, o conjunto não o encontra mais.
Esta aula parte da igualdade gerada pela data class e chega à armadilha mais comum quando ela vira entidade JPA: o toString, o hashCode e o equals percorrem relacionamentos bidirecionais, e isso pode disparar consultas, carregamentos lazy ou recursão infinita. A nota data class, enum e value class explica o que a data class gera. Aqui o foco é o que acontece com essa geração quando o objeto participa de um grafo de entidades.
o mapa da aula
A primeira parte mostra a igualdade por todos os campos e o que ela faz com um HashSet quando um campo muda. A segunda parte mostra a recursão de toString entre dois objetos que se referenciam. A terceira leva o mesmo problema para o JPA, com entidades data class, e mostra o que o Hibernate faz com o relacionamento lazy quando a classe é final. Depois vêm as boas práticas e o que ficou sem verificação.
Versões desta aula: Kotlin 2.3.21 do BOM do Spring Boot 4.1.1, com Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final e Spring Data JPA 4.1.1. A parte sem Spring foi executada no mesmo classpath. A parte com JPA usou PostgreSQL 17.11 em container.
1. igualdade por todos os campos e o conjunto que se perde
A data class gera equals, hashCode, toString e copy a partir das propriedades do construtor primário. Para uma classe de valor, como um Endereco ou um Dinheiro, isso é exatamente o que se quer: dois objetos com os mesmos campos são o mesmo valor. Para uma entidade, que tem identidade própria, a regra é outra. Duas linhas com o mesmo nome podem ser pedidos diferentes, e o identificador é que deveria dizer quem é quem.
O problema aparece quando o objeto entra num HashSet. Esse conjunto guarda cada elemento num balde escolhido pelo hashCode. Se o hashCode muda depois que o objeto foi guardado, a busca procura no balde novo e não acha o objeto, que continua no balde antigo. O experimento a seguir usa uma data class com um campo que pode mudar e uma classe comum com igualdade só pelo identificador:
data class Produto(var id: Long?, var nome: String)
class ProdutoPorId(var id: Long?, var nome: String) {
override fun equals(other: Any?): Boolean = other is ProdutoPorId && other.id == id
override fun hashCode(): Int = id.hashCode()
}
O resultado real foi:
data class: contains apos mudar o nome = false
igualdade por id: contains apos mudar o nome = true
A figura mostra a mesma sequência nos dois casos. Na linha de cima, a mudança de nome muda o hash, e a busca vai ao balde errado. Na linha de baixo, o identificador não muda, e o objeto continua onde foi guardado.
Esse experimento não depende de banco nem de Spring. A lição é anterior ao JPA: uma data class com campos mutáveis não deve ser guardada em conjuntos ou usada como chave de mapa, a não ser que os campos usados no hashCode não mudem enquanto o objeto estiver lá.
2. toString que chama a si mesmo
O segundo problema aparece quando dois objetos se referenciam. Um pedido tem uma lista de itens, e cada item aponta de volta para o pedido. Como a data class inclui todas as propriedades no toString, a chamada entra num ciclo:
data class PedidoRec(val id: Long, val itens: MutableList<ItemRec> = mutableListOf())
data class ItemRec(val id: Long, var pedido: PedidoRec? = null)
Ao chamar pedido.toString() com um item ligado a ele, o resultado real foi toString de PedidoRec: StackOverflowError. O toString do pedido chama o da lista, que chama o do item, que chama o do pedido, e assim por diante até a pilha estourar. O hashCode e o equals gerados seguem a mesma regra, e por isso a aula trata o caso de entidades em um capítulo próprio; esse ponto é uma inferência, e não foi executado.
A figura desenha esse ciclo. A seta pontilhada de volta é a que fecha o laço, e a seta vermelha é a chamada que estoura a pilha.
Em código comum, o ciclo costuma aparecer em árvores e grafos. Num JPA, ele é quase obrigatório, porque a entidade filha guarda o pai e o pai guarda a coleção de filhos. Por isso a regra prática é não deixar a data class decidir sozinha o que entra no toString, no hashCode e no equals quando há relacionamento bidirecional.
Pergunta de entrevistaPor que uma data class não serve como entidade JPA?
Porque data class compara por valor, e uma entidade deve ser comparada por identidade, normalmente o id. Dois pedidos diferentes com os mesmos campos seriam iguais, e uma entidade poderia sumir de um Set quando um campo muda. O toString gerado também percorre relacionamentos e pode forçar o carregamento de tudo. Por isso a entidade é uma class comum, com equals baseado no id, e data class fica para DTOs e valores.
3. entidades data class e relacionamento lazy
A parte com JPA usa duas entidades data class ligadas por @OneToMany e @ManyToOne, ambas com FetchType.LAZY:
@Entity
data class PedidoDc(
@Id @GeneratedValue var id: Long? = null,
var descricao: String = "",
@OneToMany(mappedBy = "pedido", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
var itens: MutableList<ItemDc> = mutableListOf()
)
@Entity
data class ItemDc(
@Id @GeneratedValue var id: Long? = null,
var produto: String = "",
@ManyToOne(fetch = FetchType.LAZY)
var pedido: PedidoDc? = null
)
Há duas perguntas a fazer. A primeira é o que o Hibernate entrega quando o item é lido e o relacionamento pedido é lazy. Como uma data class é final no código Kotlin, uma subclasse de proxy não poderia existir, e a expectativa seria que o Hibernate carregasse o pedido na hora. O experimento mostrou outra coisa:
classe do pedido carregado a partir do item: aula63.PedidoDc$HibernateProxy
O Hibernate devolveu um proxy. Ao inspecionar o bytecode com javap, a classe aula63.PedidoDc aparece como public class, e não como public final class. Ou seja, alguma etapa de compilação a abriu. A causa não é o preset spring do all-open. A lista desse preset, no jar kotlin-maven-allopen 2.3.21, tem @Component, @Async, @Transactional, @Cacheable, @SpringBootTest e @Validated, e @Entity não está nela. A abertura vem do plugin jpa do pom.xml, que é o plugin no-arg com o preset de JPA.
Para separar as causas, a mesma classe foi compilada com cada plugin sozinho. Com só spring, PedidoDc saiu como public final class. Com só jpa, saiu como public class. A documentação do plugin no-arg diz que o preset jpa declara @Entity, @Embeddable e @MappedSuperclass automaticamente, mas não diz que ele abre as classes. Essa parte é uma observação do bytecode, não da documentação.
Com a classe final, o comportamento muda. Na compilação só com spring, o mesmo código carregou o pedido real, sem proxy e sem aviso no log:
classe do pedido carregado a partir do item: aula63.PedidoDc
O Hibernate não cria proxy de uma classe final, e nesse caso o carregamento lazy virou um carregamento direto do objeto. Com só jpa, a aplicação nem sobe, porque a classe @SpringBootApplication fica final e o Spring recusa a configuração. Por isso os dois plugins são necessários juntos neste projeto, e a abertura de PedidoDc é um efeito colateral do plugin jpa que a aplicação aproveita.
A segunda pergunta é o que o toString e o hashCode fazem dentro da transação, com as coleções ainda lazy. O experimento executou:
pedido.toString() na transacao: StackOverflowError
O toString do pedido percorreu itens, e depois percorreu cada item até chegar de novo ao pedido. Com show-sql ligado, o log mostra as consultas que essa chamada disparou antes do erro:
Hibernate: select pd1_0.id,pd1_0.descricao from pedido_dc pd1_0
Hibernate: select i1_0.pedido_id,i1_0.id,i1_0.produto from item_dc i1_0 where i1_0.pedido_id=?
pedido.toString() na transacao: StackOverflowError
Ou seja, a recursão tem um custo extra: além de estourar a pilha, a chamada carrega do banco o pedido e a coleção que ainda não estavam na sessão. Uma leitura simples como “imprimir o pedido no log” pode, então, carregar o grafo sem que o código deixe isso explícito. Neste exemplo, o ciclo voltou ao mesmo objeto já carregado, por isso não houve uma consulta por item.
A documentação do Hibernate trata do mesmo tema na seção 3.4.7, “Implementing equals() and hashCode()”. Ela diz que o problema é “just a concern when using generated identifiers”, e que a abordagem recomendada é “a “better” equals/hashCode implementation, making use of a natural-id or business-key”. Sobre o conjunto, a seção afirma que o valor de equals e hashCode “should not change while the object is part of the Set”. As três citações vêm da mesma seção, e a aula as usa para justificar a regra de não usar data class como entidade quando há relacionamento bidirecional.
4. erros comuns e boas práticas
O primeiro erro é anotar uma data class com @Entity sem pensar na igualdade. Ela funciona para alguns casos simples, mas inclui todos os campos no equals, e um campo mutável muda a igualdade de uma entidade que já estava em um conjunto. A alternativa é uma classe comum, com identidade explícita pelo identificador, como a aula JPA, Hibernate e N+1 mostra.
O segundo erro é deixar o toString gerado num relacionamento bidirecional. A correção mais simples é excluir a coleção e o pai do toString, escrevendo o método à mão. Isso evita a recursão e também evita carregar o grafo sem querer quando alguém faz um log.
O terceiro erro é confiar que o hashCode de uma entidade permanece o mesmo. Num HashSet de entidades cujo hashCode inclui coleções ou campos editáveis, qualquer mudança no objeto tira o objeto do lugar onde ele foi guardado. Se o conjunto é usado para deduplicar, a deduplicação passa a falhar sem aviso.
O quarto erro é ler a documentação do tipo sem ler o compilador. Uma data class é final no código-fonte, mas o experimento mostrou que o plugin jpa do pipeline a deixou aberta. A decisão de design precisa conferir o bytecode gerado, e não só a palavra-chave do código-fonte.
A boa prática é separar classes de valor, que podem ser data class, de entidades, que devem ter identidade explícita. Para entidades, a igualdade costuma ser feita pelo identificador, com um cuidado especial para o caso em que o identificador ainda é nulo antes de salvar. O toString deve mostrar só os campos simples. E o relacionamento lazy precisa ser lido dentro da transação que o carrega, como na aula JPA, Hibernate e N+1.
5. exercícios
- Troque
Produtopor uma classe comum com igualdade peloide pelonome, e rode o experimento de novo. Explique por que o resultado muda, e o que muda nohashCodequando onomeentra na conta. - Escreva um
toStringmanual paraItemDcque mostre só oide oproduto. Confirme que otoStringdo pedido deixa de estourar a pilha quando os dois tiveremtoStringmanuais. - Chame
pedidos.toSet()dentro de um método@Transactionalcom três pedidos e conte as consultas. Diga quantas consultas aparecem antes de qualquer erro e por que a coleção é carregada nesse momento. - Pesquise, na documentação do Kotlin, a lista de restrições da
data class, e confirme se ela pode seropen. Compare com o bytecode que o experimento produziu.
6. glossário
Classe de valor: classe cuja identidade é definida pelo conteúdo. Dois objetos com os mesmos campos são iguais.
Entidade: classe cuja identidade é definida por um identificador. Duas instâncias com o mesmo id representam a mesma linha no banco.
HashSet: conjunto que guarda cada elemento num balde escolhido pelo hashCode. Mudar o hashCode de um elemento guardado quebra a busca.
Recursão de toString: chamada de toString que percorre um ciclo de referências e nunca termina, até estourar a pilha.
Proxy do Hibernate: objeto que substitui uma entidade ou um relacionamento lazy e carrega os dados quando um método é chamado.
All-open: plugin do Kotlin que torna abertas as classes marcadas com certas anotações, para que frameworks possam criar proxies.
fontes
Consultadas em 20/10/2026:
- Hibernate ORM 7.4, guia do usuário, seção 3.4.7 “Implementing equals() and hashCode()”, com as citações sobre identificadores gerados, chaves naturais e
Set: https://docs.hibernate.org/orm/7.4/userguide/html_single/Hibernate_User_Guide.html - Kotlin, all-open, com a lista do preset
springna página oficial: https://kotlinlang.org/docs/all-open-plugin.html - Kotlin, no-arg, com o preset
jpae as anotações@Entity,@Embeddablee@MappedSuperclass: https://kotlinlang.org/docs/no-arg-plugin.html - Jar
kotlin-maven-allopen2.3.21, classeAllOpenPluginNames, lido comunzipestrings, para a lista completa do presetspring. A página do Kotlin não lista@Validated, e o jar lista. - A nota interna data class, enum e value class descreve o que a
data classgera a partir do construtor primário.
Consultadas em 20/10/2026. As citações do Hibernate foram lidas com WebFetch na URL acima, na seção 3.4.7.
Verificações locais, feitas em 20/10/2026:
- Os experimentos da seção 1 e da seção 2 rodaram sem Spring, com a saída real copiada do terminal.
- O experimento da seção 3 rodou com Spring Boot 4.1.1, Hibernate 7.4.5.Final e PostgreSQL 17.11, com
show-sqlligado. A classe do proxy, as consultas e a saída detoStringforam copiadas da execução. - A abertura da classe
PedidoDcfoi conferida comjavap -pem três builds: com os dois plugins, com sóspringe com sójpa.
Código do experimento (arquivo aula63/Main.kt):
package aula63
import jakarta.persistence.CascadeType
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
// Parte 1, sem Spring: a igualdade gerada pela data class usa todos os campos.
data class Produto(var id: Long?, var nome: String)
class ProdutoPorId(var id: Long?, var nome: String) {
override fun equals(other: Any?): Boolean = other is ProdutoPorId && other.id == id
override fun hashCode(): Int = id.hashCode()
}
data class PedidoRec(val id: Long, val itens: MutableList<ItemRec> = mutableListOf())
data class ItemRec(val id: Long, var pedido: PedidoRec? = null)
fun demoHashSet() {
val data = Produto(1, "cafe")
val conjuntoDataClass = hashSetOf(data)
data.nome = "cafe especial"
println("data class: contains apos mudar o nome = ${conjuntoDataClass.contains(data)}")
val comum = ProdutoPorId(1, "cafe")
val conjuntoComum = hashSetOf(comum)
comum.nome = "cafe especial"
println("igualdade por id: contains apos mudar o nome = ${conjuntoComum.contains(comum)}")
}
fun demoRecursao() {
val pedido = PedidoRec(1)
val item = ItemRec(10, pedido)
pedido.itens.add(item)
try {
println(pedido.toString())
} catch (e: StackOverflowError) {
println("toString de PedidoRec: StackOverflowError")
}
}
// Parte 2, com JPA: a mesma armadilha com entidades.
@Entity
data class PedidoDc(
@Id @GeneratedValue var id: Long? = null,
var descricao: String = "",
@OneToMany(mappedBy = "pedido", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
var itens: MutableList<ItemDc> = mutableListOf()
)
@Entity
data class ItemDc(
@Id @GeneratedValue var id: Long? = null,
var produto: String = "",
@ManyToOne(fetch = FetchType.LAZY)
var pedido: PedidoDc? = null
)
interface PedidoDcRepository : JpaRepository<PedidoDc, Long>
interface ItemDcRepository : JpaRepository<ItemDc, Long>
@Service
class ServicoDc(private val pedidos: PedidoDcRepository, private val itens: ItemDcRepository) {
@Transactional
fun semear() {
(1..3).forEach { n ->
val p = PedidoDc(descricao = "pedido $n")
repeat(2) { k -> p.itens.add(ItemDc(produto = "p$n-$k", pedido = p)) }
pedidos.save(p)
}
}
@Transactional(readOnly = true)
fun tipoDoPedidoDoItem(): String {
val item = itens.findAll().first()
return item.pedido!!::class.java.name
}
@Transactional(readOnly = true)
fun toStringDentroDaTransacao(): String = try {
pedidos.findAll().first().toString()
} catch (e: StackOverflowError) {
"StackOverflowError"
}
}
@SpringBootApplication
class AulaDataClassApplication {
@Bean
fun cenarios(servico: ServicoDc) = CommandLineRunner {
println("== parte 1, sem JPA ==")
demoHashSet()
demoRecursao()
println("== parte 2, com JPA ==")
servico.semear()
println("classe do pedido carregado a partir do item: ${servico.tipoDoPedidoDoItem()}")
println("pedido.toString() na transacao: ${servico.toStringDentroDaTransacao()}")
}
}
fun main() {
runApplication<AulaDataClassApplication>().close()
}
Não verificado nesta aula: a formação de proxies em relacionamentos LAZY com @OneToOne, o efeito de copy() sobre uma entidade já gerenciada, e a causa exata de o plugin jpa abrir a classe, porque a documentação consultada não a descreve.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- data class, enum e value class
- JPA, Hibernate e N+1
- Null safety e tipos nuláveis