Data Class e Copy em Kotlin

1. Visão geral

data class é um dos recursos que fazem Kotlin escrever código muito mais conciso que Java quando você trabalha com classes que apenas contêm dados (DTO, model, value object). Este artigo ajuda você a entender o que Kotlin gera automaticamente para uma data class, como usar copy() para criar cópias com alguns campos modificados, e as armadilhas comuns como comparações incorretas, usar var em data class, ou herança de data class.

2. O Que É Uma Data Class?

Quando você declara uma classe com a palavra-chave data, o compilador Kotlin gera automaticamente alguns métodos com base nas propriedades declaradas no construtor primário.

data class Customer(
    val id: Long,
    val name: String,
    val email: String
)

Com apenas estas 4 linhas, Kotlin gera automaticamente:

  • equals() / hashCode() baseado em id, name, email.
  • toString() no formato Customer(id=1, name=..., email=...).
  • copy() para criar cópias com alguns campos alterados.
  • componentN() para suportar declaração destructuring.

Comparado com Java, você teria que escrever uma classe com construtor, getters, equals, hashCode, toString — cerca de 40-50 linhas para a mesma funcionalidade.

2.1. toString() Padrão

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)

O toString() gerado automaticamente é muito conveniente ao logar ou debugar, você não precisa escrever um.

3. equals() e hashCode() Por Valor

A maior diferença entre data class e uma classe comum é que equals() compara por valor (igualdade estrutural) em vez de por referência.

fun main() {
    val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
    val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

    println(c1 == c2)   // compara por valor
    println(c1 === c2)  // compara por referência
}
true
false
  • == chama equals(), dois objetos com os mesmos campos são iguais, mesmo que sejam referências diferentes.
  • === verifica se as duas variáveis apontam para o mesmo objeto na memória.

Nota: Com uma classe comum (não uma data class), == por padrão compara por referência como ===, a menos que você sobrescreva manualmente equals().

Como hashCode() também é gerado com base nas propriedades, você pode usar data class como chave em HashMap ou elemento em HashSet de forma segura.

fun main() {
    val set = hashSetOf(
        Customer(1, "An Nguyen", "an@nonladev.com"),
        Customer(1, "An Nguyen", "an@nonladev.com")
    )
    println(set.size)
}
1

Dois objetos com os mesmos valores são considerados iguais, o HashSet mantém apenas 1 elemento.

4. Função copy() — Criando Cópias Imutáveis

Se você usar val para as propriedades, Customer é imutável — você não pode modificar os campos diretamente após criação. Para “alterar” alguns valores, Kotlin gera a função copy() que permite criar um novo objeto, mantendo inalterados os campos que não foram passados.

fun main() {
    val original = Customer(1, "An Nguyen", "an@nonladev.com")
    val updated = original.copy(email = "an.new@nonladev.com")

    println(original)
    println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
  • original não é alterado em absoluto — esta é a essência da imutabilidade.
  • updated é um novo objeto, que difere apenas em email, os outros campos são mantidos conforme estavam.

Este é um padrão muito comum ao trabalhar com estado em UI (Compose, MVI) ou ao atualizar uma entidade em lógica de negócio sem querer alterar o objeto diretamente.

4.1. copy() Com Argumentos Nomeados

Você pode passar múltiplos parâmetros de uma vez, pelo nome (named arguments) para evitar confusão de ordem:

val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")

Se não usar argumentos nomeados mas seguir a ordem de posição, o código é fácil de confundir quando a classe tem muitos campos do mesmo tipo String.

5. Shallow Copy — Armadilha Com Objetos Mutáveis Dentro

copy() apenas cria uma shallow copy: copia referências dos campos, não copia profundamente objetos complexos dentro.

data class Order(
    val id: Long,
    val items: MutableList<String>
)

fun main() {
    val order1 = Order(1, mutableListOf("Book"))
    val order2 = order1.copy()

    order2.items.add("Pen")

    println(order1.items) // é afetada!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy() apenas cria um novo objeto Order, mas o campo items ainda aponta para a mesma MutableList.
  • Modificar order2.items faz order1.items mudar também — este é um erro muito comum ao usar coleções mutáveis em data class.

Como corrigir isto: use List imutável (toList()) ou copie manualmente a coleção no construtor.

data class Order(
    val id: Long,
    val items: List<String>
)

fun main() {
    val order1 = Order(1, listOf("Book"))
    val order2 = order1.copy(items = order1.items + "Pen")

    println(order1.items)
    println(order2.items)
}
[Book]
[Book, Pen]

Use List<String> imutável e o operador + para criar uma nova lista em vez de alterar, mantendo o espírito da imutabilidade de data class.

Nota: Se você precisar de um deep copy real (nested data class contendo outra data class), você deve escrever manualmente a lógica recursiva de cópia, Kotlin não gera deep copy automaticamente.

6. Declaração Destructuring

Graças ao componentN() gerado automaticamente, você pode “destructurar” uma data class em múltiplas variáveis separadas:

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    val (id, name, email) = customer

    println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com

A ordem das variáveis corresponde à ordem das propriedades no construtor primário, não em nome. Alterar a ordem das propriedades declaradas tornará destructuring incorreto — você precisa tomar cuidado ao refatorar.

Nota: Você deve fazer destructuring apenas quando a data class tem poucos campos (2-3). Com classes com muitos campos, use nomes de propriedade diretamente para tornar o código mais fácil de ler.

7. Limitações da Data Class

7.1. Não Pode Ser Herdada

data class não pode herdar de outra data class (não pode ser open junto com data da forma que herda campos automaticamente):

// Erro de compilação se tentar isto:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlin apenas permite que data class implemente interface, ou herde de uma classe comum (non-data) que não tenha propriedades no construtor primário. Razão: equals()/copy() gerados automaticamente serão muito difíceis de definir corretamente quando há herança multi-nível de campos.

Solução comum: composição em vez de herança.

data class VipCustomer(
    val customer: Customer,
    val tier: String
)

7.2. Construtor Primário Deve Ter Pelo Menos 1 Propriedade

// Erro: data class deve ter pelo menos 1 parâmetro no construtor primário
// data class Empty

7.3. Apenas Propriedades no Construtor Primário São Consideradas

data class Product(val name: String) {
    var discount: Int = 0
}

fun main() {
    val p1 = Product("Laptop").apply { discount = 10 }
    val p2 = Product("Laptop").apply { discount = 20 }

    println(p1 == p2) // true, discount não participa de equals()
}
true

discount é declarado no corpo, não está no construtor primário, então não é considerado em equals(), hashCode(), toString(), copy(). Este é um erro fácil de se confundir — se um campo é importante para comparação, deve ser colocado no construtor primário.

8. Testando Com JUnit 5 e AssertJ

Dependências necessárias (Gradle Kotlin DSL):

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")

Escreva testes para verificar o comportamento de copy() e equals():

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `copy should keep unchanged fields and update specified ones`() {
        val original = Customer(1, "An Nguyen", "an@nonladev.com")

        val updated = original.copy(email = "an.new@nonladev.com")

        assertThat(updated.id).isEqualTo(original.id)
        assertThat(updated.name).isEqualTo(original.name)
        assertThat(updated.email).isEqualTo("an.new@nonladev.com")
        assertThat(original.email).isEqualTo("an@nonladev.com")
    }

    @Test
    fun `two customers with same fields should be equal`() {
        val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
        val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

        assertThat(c1).isEqualTo(c2)
        assertThat(c1).isNotSameAs(c2)
    }
}
  • isEqualTo verifica a igualdade por valor, correto com a essência de equals() de data class.
  • isNotSameAs confirma que c1 e c2 são dois objetos diferentes na memória, embora sejam iguais em valor.
  • O primeiro teste confirma que copy() funciona corretamente: campos não especificados são mantidos, campos especificados são atualizados, o objeto original não é modificado.

9. Quando Não Usar Data Class

  • Entity JPA/Hibernate: equals()/hashCode() em todos os campos (incluindo campos gerados como id antes de persistir) é fácil causar erros ao usar em HashSet ou comparar entidades já salvas/não salvas no BD. Deve-se escrever equals()/hashCode() manualmente baseado em id para entidades.
  • Classe com lógica de negócio complexa: data class é destinada para data holder, não é apropriado quando a classe precisa de muitos comportamentos e herança em múltiplos níveis.
  • Classe que precisa otimizar memória com muitos campos mutáveis aninhados: considere usar builder pattern ou uma classe comum se shallow copy causa riscos difíceis de controlar.

10. Conclusão

data class ajuda você a eliminar boilerplate code ao trabalhar com DTO/model em Kotlin: gera automaticamente equals(), hashCode(), toString(), copy() e suporta destructuring. A função copy() é uma ferramenta poderosa para manter imutabilidade, mas você precisa lembrar que ela apenas cria shallow copy — tenha cuidado com campos contendo coleções ou objetos mutáveis dentro. Evite usar data class para entidades JPA ou classes com lógica de negócio complexa, e sempre coloque campos importantes no construtor primário para que equals()/copy() funcionem corretamente como esperado.

kotlindata-classcopyimmutabilitykotlin-basics