Pular para o conteúdo

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

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

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.

Mudar o campo que entra no hash. Produto(1, cafe) → nome = cafe especial; nome = cafe especial → contains(produto); contains(produto) → false…
figura 1 · Mudar o campo que entra no hash

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.

toString que chama a si mesmo. pedido.toString() → PedidoDc.toString() (chama); PedidoDc.toString() → ItemDc.toString() (toString dos itens)…
figura 2 · toString que chama a si mesmo

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

  1. Troque Produto por uma classe comum com igualdade pelo id e pelo nome, e rode o experimento de novo. Explique por que o resultado muda, e o que muda no hashCode quando o nome entra na conta.
  2. Escreva um toString manual para ItemDc que mostre só o id e o produto. Confirme que o toString do pedido deixa de estourar a pilha quando os dois tiverem toString manuais.
  3. Chame pedidos.toSet() dentro de um método @Transactional com três pedidos e conte as consultas. Diga quantas consultas aparecem antes de qualquer erro e por que a coleção é carregada nesse momento.
  4. Pesquise, na documentação do Kotlin, a lista de restrições da data class, e confirme se ela pode ser open. 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:

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-sql ligado. A classe do proxy, as consultas e a saída de toString foram copiadas da execução.
  • A abertura da classe PedidoDc foi conferida com javap -p em três builds: com os dois plugins, com só spring e 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

↑ 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
?