Cuándo NO usar una data class
Entidades con identidad propia, jerarquías y herencia, y superficies de API que no pueden permitirse un copy público. Los tres territorios donde la palabra data miente, y las dos alternativas: la clase normal y la value class.
Después de cuatro lecciones celebrando lo que una data class genera, toca la lección incómoda: la palabra data es una afirmación sobre la naturaleza de un tipo, y hay tipos para los que esa afirmación es sencillamente falsa. Ponerla por costumbre en todo lo que transporta datos produce una categoría entera de problemas que no se manifiestan en el momento de escribir la clase, sino meses después, en forma de objetos perdidos en un conjunto, jerarquías que no se pueden expresar y librerías que no se pueden evolucionar sin romper a sus consumidores.
- Distinguir un tipo de valor de una entidad con identidad y elegir la herramienta de cada uno.
- Explicar por qué
dataes incompatible conopen,abstract,sealedeinner. - Evaluar el coste en compatibilidad de exponer una
data classen una API pública. - Elegir con criterio entre una clase normal y una
value class.
Identidad frente a valor
La distinción es anterior a Kotlin y sobrevive a cualquier lenguaje. Un valor es un tipo cuya identidad es su contenido completo: dos importes de cincuenta euros son el mismo importe, y no tiene sentido preguntar cuál de los dos es. Una entidad es un tipo con identidad propia y continuidad en el tiempo: un usuario sigue siendo el mismo usuario cuando cambia de correo, y dos filas con todos los campos idénticos pero distinto identificador son dos usuarios diferentes.
El equals generado por una data class implementa la primera semántica. Aplicarlo a una entidad produce afirmaciones falsas en ambas direcciones:
// MAL: Usuario es una entidad, no un valor
data class Usuario(val id: Long, val email: String)
val antes = Usuario(1, "a@ejemplo.test")
val despues = antes.copy(email = "b@ejemplo.test")
println(antes == despues) // false, y sin embargo es el mismo usuario
// BIEN: la identidad la define el identificador
class Usuario(val id: Long, val email: String) {
override fun equals(other: Any?): Boolean = other is Usuario && other.id == id
override fun hashCode(): Int = id.hashCode()
override fun toString(): String = "Usuario(id=$id)"
}
El caso más conocido de este error es la entidad persistida. Una data class mapeada contra una base de datos acumula tres problemas a la vez: su hashCode cambia cuando el motor asigna el identificador tras la inserción, dejando huérfano cualquier objeto ya guardado en un conjunto; su toString recorre todas las propiedades y dispara la carga perezosa de las relaciones, arrastrando media base de datos a un registro de diagnóstico; y su copy ofrece una forma cómoda de fabricar duplicados con el mismo identificador que el motor considerará conflictos.
Si al leer la clase puedes decir sin dudar que dos instancias con el mismo contenido son intercambiables en todo tu programa, es un valor y data es correcto. Si el tipo tiene un identificador, un ciclo de vida, o alguien puede decir de él que ha cambiado, es una entidad y data va a mentir.
Jerarquías: por qué data y open no conviven
Una data class no puede ser open, ni abstract, ni sealed, ni inner. La prohibición no es un capricho: es la única forma de mantener el contrato de equals. Si una data class pudiera tener subclases, una instancia de la clase base y una de la derivada con los mismos campos base se declararían iguales en una dirección y desiguales en la otra, rompiendo la simetría; y copy devolvería siempre una instancia de la clase base, degradando en silencio el tipo del objeto.
Lo que sí funciona, y es el patrón idiomático, es la combinación inversa: una interfaz o clase sealed como jerarquía, con data class en las hojas.
sealed interface Evento {
val instante: Long
}
data class Pulsacion(override val instante: Long, val x: Int, val y: Int) : Evento
data class Texto(override val instante: Long, val contenido: String) : Evento
data object Cierre : Evento { override val instante: Long = 0 }
Aquí la jerarquía la aporta sealed, que da exhaustividad en el when, y el valor lo aportan las hojas, que son finales y por tanto pueden generar un equals correcto. Cada nivel hace lo que sabe hacer.
Queda el caso híbrido y venenoso: una data class que hereda de una clase abierta con estado. Es legal desde Kotlin 1.1 y casi siempre un error, porque el equals generado solo mira las propiedades del constructor primario de la subclase e ignora por completo lo heredado. Dos instancias que difieren únicamente en el estado del padre se declaran iguales, y lo hacen sin ningún aviso.
flowchart TD
A[Necesito un tipo nuevo] --> I{Necesito ramas o variantes}
I -->|Si| J[sealed arriba y data class en las hojas]
I -->|No| B{Su identidad es todo su contenido}
B -->|No, tiene identificador| C[Clase normal con equals por id]
B -->|Si| D{Cuantas propiedades lleva}
D -->|Una sola envoltura| E[value class con JvmInline]
D -->|Varias| F{Cruza el limite de un modulo publico}
F -->|Si| G[Clase normal con constructor controlado]
F -->|No| H[data class]La data class como superficie de API
Marcar una clase como data en una librería publica mucho más de lo que parece. Las funciones componentN convierten el orden de tu constructor en contrato, copy convierte la lista completa de propiedades en una firma pública, y toString convierte todo el contenido en material de registro. La consecuencia práctica es que añadir una propiedad a una data class publicada es un cambio incompatible: la firma de copy cambia, aparecen componentN nuevos y los consumidores ya compilados dejan de enlazar.
// v1 publicada
data class Config(val host: String, val puerto: Int)
// v2: parece aditivo y con valor por defecto, pero copy cambia de firma
data class Config(val host: String, val puerto: Int, val tls: Boolean = true)
Para tipos de configuración de una librería, la alternativa madura es una clase normal con constructor controlado, o un constructor mediante función DSL, que permite añadir opciones sin tocar ninguna firma existente:
class Config private constructor(
val host: String,
val puerto: Int,
val tls: Boolean,
) {
class Builder(var host: String = "localhost") {
var puerto: Int = 443
var tls: Boolean = true
fun build() = Config(host, puerto, tls)
}
}
fun config(bloque: Config.Builder.() -> Unit): Config =
Config.Builder().apply(bloque).build()
// Anadir una opcion nueva al Builder no cambia ninguna firma existente
val c = config { host = "api.ejemplo.test"; puerto = 8443 }
Y si insistes en data, al menos declara el constructor como interno o privado y anota la clase con @ConsistentCopyVisibility para que copy no ofrezca un atajo que se salte tus validaciones. Conviene además situar la decisión en su justo tamaño: este argumento pesa mucho cuando publicas un artefacto que otros equipos compilan contra una versión y ejecutan contra otra, y pesa muy poco dentro de un módulo de aplicación que se recompila entero en cada despliegue. La compatibilidad binaria es un problema de quien publica, no de quien consume su propio código.
Hay un último argumento, menos técnico y más operativo: el toString generado imprime todas las propiedades del constructor. Una data class que lleve una contraseña, un token o un dato personal filtra ese contenido a los registros en cuanto alguien interpole la instancia en un mensaje, y ese alguien puede ser un framework que ni siquiera controlas.
Las alternativas
Clase normal
Cuando el tipo tiene identidad, ciclo de vida, invariantes que defender o ambición de evolucionar en una API pública. Escribes equals y hashCode a mano, o directamente no los escribes y aceptas la igualdad por referencia, que para una entidad suele ser la respuesta correcta.
value class
Cuando el tipo es una envoltura de un único valor y existe solo para que el sistema de tipos distinga un identificador de usuario de un identificador de pedido. En tiempo de ejecución la envoltura desaparece siempre que se pueda, y la seguridad sale gratis.
La value class merece detalle porque es la respuesta exacta a un patrón muy frecuente: la data class de una sola propiedad.
@JvmInline
value class IdUsuario(val valor: Long)
@JvmInline
value class Email(val valor: String) {
init { require("@" in valor) { "Correo invalido" } }
}
fun cargar(id: IdUsuario): Usuario = TODO()
// cargar(IdPedido(7)) no compila: el compilador ya no confunde dos Long
El compilador representa la instancia con su valor subyacente allí donde puede, de modo que no hay asignación de memoria ni indirección: la seguridad de tipos deja de costar. Pero la envoltura reaparece, es decir, hay boxing, cuando el valor se usa como argumento de un tipo genérico, cuando se guarda en una colección, cuando se declara como nulable o cuando se trata a través de una interfaz que implemente. Además, el requisito es estricto: exactamente una propiedad en el constructor primario, marcada val, sin campo de respaldo adicional y sin herencia. A cambio genera equals, hashCode y toString derivados de ese único valor, que es justo lo que querías de la data class que ibas a escribir.
El hábito de anteponer data a toda clase que contenga campos nace de una lectura sintáctica del modificador, como si dijera esta clase guarda datos, cuando lo que dice es esta clase es un valor. La diferencia entre ambas lecturas es la diferencia entre una decisión de escritura y una decisión de modelado, y explica por qué el mismo modificador que resulta indispensable en un objeto de estado de pantalla se vuelve tóxico en una entidad persistida. Un valor no tiene historia: no cambia, se sustituye; no se pregunta cuál de dos copias es la buena porque son indistinguibles; y su identidad coincide exactamente con la tupla de sus campos, que es precisamente el contrato que el compilador genera. Una entidad tiene historia: existe en el tiempo, cambia de contenido conservando quién es, se referencia desde otros sitios, y su identidad es un identificador y no un contenido. Cuando aplicas la maquinaria de valores a una entidad, cada miembro generado se convierte en una fuente independiente de fallos: equals niega la identidad al comparar contenidos que cambian, hashCode la vuelve inestable y hace desaparecer objetos de las colecciones basadas en dispersión, copy fabrica duplicados con la misma identidad que ninguna capa de persistencia sabe reconciliar, toString filtra estado y dispara efectos, y las componentN publican como contrato un orden de campos que era una decisión interna. Y cuando aplicas la maquinaria de valores a un tipo público de librería, congelas la forma de tu constructor en la superficie binaria y conviertes cada campo nuevo en una ruptura para tus consumidores. La disciplina que se sigue de todo esto no es evitar las data class, que son una de las mejores herramientas del lenguaje, sino gastar treinta segundos antes de teclear la palabra para responder tres preguntas: si dos instancias con el mismo contenido son la misma cosa, si este tipo va a necesitar ramas o herencia, y si alguien fuera de mi módulo va a depender de la forma exacta de su constructor. Con esas tres respuestas, la elección entre data class, clase normal y value class deja de ser una cuestión de gusto y se convierte en lo que siempre fue: una consecuencia del dominio que estás modelando.
- Haz inventario de tus
data classy marca cada una como valor o entidad; para las entidades, escribe elequalspor identificador que deberían tener. - Busca una
data classde una sola propiedad y conviértela envalue classcon@JvmInline; comprueba después dónde vuelve a aparecer la envoltura. - Coge un tipo de tu API pública y enumera todo lo que has publicado sin querer: firma de
copy, número decomponentNy contenido deltoString. - Busca cualquier
data classque herede de una clase con estado y comprueba con dos instancias que suequalsignora lo heredado.