Serialización: kotlinx frente a Moshi y el esquema que cambia
Convertir bytes en objetos parece un detalle mecánico y es donde se decide la resistencia de una aplicación al paso del tiempo. Esta lección compara kotlinx.serialization con Moshi por su modelo de generación y no por su sintaxis, desmonta la confusión entre campo ausente, campo nulo y valor por defecto, muestra cómo se distinguen los tres cuando el protocolo lo exige, y establece las reglas de evolución de esquema que permiten que una versión antigua instalada en un teléfono siga funcionando cuando el servidor ya cambió.
La serialización tiene una reputación inmerecida de trabajo aburrido y resuelto. Se elige una biblioteca, se anotan unas clases de datos y se pasa a lo interesante. Esa percepción sobrevive exactamente hasta el primer incidente en producción, que casi siempre tiene la misma forma: el servidor añadió un campo, o dejó de enviar uno, o cambió un valor de enumeración, y una fracción de los usuarios empezó a ver una pantalla vacía sin que nadie tocara una línea del cliente. El problema de fondo no es técnico sino temporal. Una clase de datos anotada es una afirmación sobre la forma de unos bytes que llegarán en el futuro, escrita por alguien que solo conoce la forma que tenían en el pasado, y compilada dentro de un binario que sobrevivirá años en dispositivos que quizá nunca se actualicen. Entender qué hace exactamente cada biblioteca cuando la realidad se aparta de esa afirmación es lo que separa una capa de datos que degrada con elegancia de una que se rompe entera por un campo de más.
- Comparar
kotlinx.serializationy Moshi por su modelo de generación, sus garantías y su alcance, no por su sintaxis. - Distinguir con rigor las tres situaciones que el vocabulario común confunde: campo ausente, campo con valor nulo y valor por defecto.
- Configurar el analizador para que tolere lo desconocido sin ocultar los fallos que sí importan.
- Aplicar reglas de evolución de esquema que mantengan compatible una versión antigua del cliente frente a un servidor que ya cambió.
Dos modelos de generación, no dos sintaxis
kotlinx.serialization funciona mediante un complemento del compilador de Kotlin que, al ver la anotación @Serializable, genera un serializador para esa clase durante la propia compilación. No hay reflexión en tiempo de ejecución, el descriptor del tipo se conoce estáticamente y el mecanismo funciona igual en todos los destinos que Kotlin sabe compilar, incluidos los que no tienen reflexión en absoluto. Esa es la razón profunda por la que es la opción natural en un proyecto multiplataforma y por la que se lleva bien con la reducción agresiva de código.
Moshi es una biblioteca de Java pensada para el mundo de la máquina virtual, con dos modos de operación. El modo de generación produce adaptadores mediante un procesador de anotaciones, con un rendimiento equivalente y sin reflexión en el camino caliente. El modo reflexivo interpreta la clase en tiempo de ejecución, lo que resulta cómodo y cuesta arranque, memoria y reglas de conservación al minimizar el binario. Su virtud histórica y todavía vigente es la calidad de su modelo de adaptadores: componer, envolver y sustituir la conversión de un tipo concreto es más natural que en casi cualquier alternativa.
Complemento de compilador
kotlinx.serialization conoce el esquema en tiempo de compilación, no necesita reflexión y funciona igual en Android, en servidor y en iOS. Es la elección evidente si el módulo de datos se comparte.
Adaptadores componibles
Moshi destaca cuando hay que intervenir la conversión de tipos concretos, encadenar transformaciones o convivir con jerarquías heredadas de Java sin reescribirlas.
Coste de arranque
El modo reflexivo de Moshi paga inspección en la primera conversión de cada tipo. El modo generado y el complemento de compilador no pagan nada, y eso se nota en el arranque en frío de la gama baja.
Reducción de código
Sin reflexión no hacen falta reglas de conservación para los modelos. Con reflexión, una regla olvidada produce un fallo que solo aparece en las compilaciones de publicación.
La declaración de un mismo modelo en ambas bibliotecas es casi idéntica, y esa semejanza superficial es precisamente lo que hace que la discusión se desvíe hacia la sintaxis. Lo que cambia no es cómo se escribe sino cuándo y dónde se decide el esquema.
// kotlinx.serialization: el esquema se resuelve al compilar
@Serializable
data class UsuarioDto(
val id: String,
@SerialName("display_name") val nombre: String,
val verificado: Boolean = false,
)
// Moshi con generacion: adaptador producido por el procesador
@JsonClass(generateAdapter = true)
data class UsuarioMoshi(
val id: String,
@Json(name = "display_name") val nombre: String,
val verificado: Boolean = false,
)
La comparación honesta termina en un empate técnico para el caso corriente y en dos criterios de decisión claros. Si el modelo de datos se comparte entre plataformas o si el proyecto ya es de Kotlin en su totalidad, kotlinx.serialization es la respuesta por defecto. Si existe una inversión importante en adaptadores propios, en interoperabilidad con Java o en una jerarquía polimórfica compleja ya modelada, migrar por moda es gastar semanas para llegar al mismo sitio.
Ausente, nulo y por defecto son tres cosas
Aquí está la confusión que más incidentes produce, y se despeja enunciando la distinción con precisión. Un campo puede no aparecer en el documento recibido; puede aparecer con el valor nulo explícito; o puede no aparecer y ser rellenado con un valor por defecto declarado en el cliente. Son tres situaciones distintas con tres significados distintos, y el comportamiento por defecto de cada biblioteca las colapsa de maneras diferentes.
En kotlinx.serialization, un campo sin valor por defecto que no aparece en el documento provoca una excepción por campo ausente, incluso si su tipo es anulable. Esa severidad es deliberada y a menudo la correcta: si el contrato dice que ese dato existe, su ausencia es un fallo del contrato y no un valor vacío. Un campo con valor por defecto declarado, en cambio, simplemente lo toma. Y un nulo explícito sobre un tipo no anulable falla, salvo que se active la coerción de valores de entrada, que lo sustituye por el valor por defecto.
@Serializable
data class ProductoDto(
val id: String,
val nombre: String,
val descripcion: String? = null, // ausente o nulo, ambos valen
val etiquetas: List<String> = emptyList(), // ausente, lista vacia
val descuento: Int = 0, // ausente o nulo con coercion
)
val analizador = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
}
Las tres opciones del analizador merecen entenderse una a una porque cada una compra algo distinto. Ignorar claves desconocidas es innegociable: sin ella, el día en que el servidor añada un campo que tu versión no conoce, la aplicación instalada dejará de analizar la respuesta entera. La coerción convierte nulos inesperados y valores de enumeración desconocidos en el valor por defecto en lugar de fallar. Y desactivar los nulos explícitos hace dos cosas: al escribir omite los campos nulos en lugar de emitirlos, y al leer permite que un campo anulable ausente valga nulo sin necesidad de declararle un valor por defecto.
Moshi en su modo generado se comporta de forma parecida pero no idéntica: un campo ausente toma el valor por defecto si lo hay y falla si no lo hay, y un nulo explícito sobre un tipo no anulable produce una excepción de datos que nombra el campo y su ruta dentro del documento. Esa calidad de mensaje es una de sus mejores propiedades prácticas cuando hay que diagnosticar un documento grande.
En una API de actualización parcial, omitir un campo significa no lo toques y enviarlo nulo significa bórralo. Ninguna clase de datos corriente puede expresar esa diferencia, porque ambos casos aterrizan en la misma propiedad valiendo nulo. La solución es un tipo envoltorio de tres estados con su propio serializador, de modo que el modelo distinga ausencia de borrado explícito. Es más trabajo, y es la única forma de no perder información que el protocolo sí transmite.
El esquema cambia, y tu binario no
La regla que gobierna toda esta sección es que el cliente instalado no se actualiza cuando el servidor sí. En cualquier momento existe una población de versiones antiguas en circulación, y el conjunto de decisiones de compatibilidad tiene que tomarse pensando en ellas y no en la versión que hoy tienes abierta en el editor.
flowchart TD
A[Llega un documento] --> B{La clave existe en el modelo}
B -->|No| C[Se ignora si asi esta configurado]
B -->|Si| D{Aparece el campo}
D -->|No| E{Hay valor por defecto}
E -->|Si| F[Toma el valor por defecto]
E -->|No| G[Fallo por campo ausente]
D -->|Si| H{El valor es nulo}
H -->|Si| I{El tipo admite nulo}
I -->|Si| J[Queda nulo]
I -->|No| K[Coercion al valor por defecto o fallo]
H -->|No| L[Se convierte al tipo declarado]
style F fill:#a6e3a1,color:#11111b
style G fill:#f38ba8,color:#11111b
style K fill:#f9e2af,color:#11111bDe ese diagrama salen cuatro reglas operativas. La primera es que todo campo añadido después de la versión inicial debe llegar con valor por defecto en el cliente, porque las versiones antiguas no lo conocen y las nuevas deben tolerar que un servidor todavía no desplegado no lo envíe. La segunda es que un campo nunca se elimina ni se renombra: se deja de usar y se sigue enviando, o se declara con un alias que acepta el nombre antiguo y el nuevo simultáneamente durante la transición.
La tercera regla afecta a las enumeraciones y es la fuente más frecuente de fallos silenciosos en aplicaciones maduras. Un valor nuevo en un catálogo del servidor rompe cualquier cliente que traduzca esa cadena a una enumeración cerrada. La defensa es una constante de reserva que absorba lo desconocido, mediante coerción hacia el valor por defecto en kotlinx.serialization o mediante un adaptador con repliegue en Moshi. La cuarta afecta a las jerarquías polimórficas: cuando el tipo se decide por un campo discriminador, hay que declarar qué ocurre con un discriminador que el cliente no reconoce, y la respuesta sana casi siempre es una variante desconocida que la interfaz pueda ignorar sin dejar de dibujar el resto de la lista.
@Serializable
sealed interface Bloque {
@Serializable @SerialName("texto")
data class Texto(val contenido: String) : Bloque
@Serializable @SerialName("imagen")
data class Imagen(val url: String, val alto: Int = 0) : Bloque
// Absorbe cualquier discriminador futuro sin romper la pantalla.
@Serializable @SerialName("desconocido")
data object Desconocido : Bloque
}
El renombrado merece una precisión operativa, porque la segunda regla suele leerse como una prohibición absoluta y no lo es. Renombrar es posible si durante la transición el modelo acepta ambos nombres a la vez, con la anotación de nombres alternativos en kotlinx.serialization o con un adaptador que contemple los dos en Moshi. Lo que no es posible es hacer el cambio de golpe: hay que aceptar ambos, esperar a que la población de clientes antiguos sea despreciable —lo que se mide, no se supone— y solo entonces retirar el nombre viejo.
Conviene también resistir la tentación de resolver todo esto con una versión en la ruta del punto final. Versionar la API es una herramienta legítima para cambios que rompen el contrato de raíz, pero usarla como sustituto de la compatibilidad hacia atrás multiplica el trabajo del servidor por el número de versiones vivas y no elimina el problema: dentro de cada versión sigue habiendo evolución, y sigue haciendo falta que los campos nuevos lleguen con valor por defecto.
Hay una última decisión estructural que conviene tomar pronto porque es carísima de revertir: los tipos que se anotan para serializar no deben ser los tipos que usa el resto de la aplicación. Un modelo de transferencia refleja el capricho del servidor —campos anulables que en tu dominio nunca lo son, fechas como cadenas, códigos numéricos, nombres en otro idioma— y su forma cambia cuando el servidor cambia. Un modelo de dominio expresa lo que tu aplicación entiende, con tipos que hacen imposibles los estados inválidos. La función de mapeo entre ambos es donde se concentra la validación, y su existencia es lo que permite que un cambio remoto se detenga en la capa de datos en vez de propagarse hasta la pantalla.
Toda la disciplina de esta lección se sostiene sobre una idea que rara vez se enuncia y que reordena por completo el criterio: el interlocutor real de tu código de serialización no es el servidor, es el futuro. Cuando se escribe una clase anotada se tiende a imaginar una conversación entre dos partes presentes, el cliente y el servidor, que se ponen de acuerdo sobre un formato y lo respetan. Esa imagen es falsa en el único aspecto que importa. El cliente y el servidor no coexisten en una sola versión cada uno: coexisten en decenas de versiones simultáneas, distribuidas de forma desigual entre millones de dispositivos, algunas de ellas escritas hace tres años por gente que ya no está en el equipo, muchas instaladas en teléfonos que no volverán a actualizarse nunca porque su dueño no tiene espacio libre o porque el fabricante dejó de dar soporte. En ese escenario, la pregunta pertinente ante cada campo que se añade o se quita no es si el cliente y el servidor están de acuerdo hoy, sino si la versión del cliente publicada hace dos años sobrevivirá al despliegue del martes que viene. Y esa pregunta tiene consecuencias asimétricas que conviene interiorizar: ser estricto al leer es una elección que solo se puede pagar cuando se controla al emisor, mientras que ser generoso al leer y conservador al escribir es lo único que escala cuando no se controla ni al emisor ni al receptor ni el calendario de ninguno de los dos. Pero la generosidad tiene un límite que también hay que fijar, porque llevada al extremo produce el peor de los mundos: una aplicación que nunca falla al analizar y que muestra datos silenciosamente incompletos, que es una forma de corrupción mucho más difícil de detectar que un error ruidoso. La regla que resuelve la tensión es sencilla de enunciar y exige criterio para aplicarla: sé tolerante con lo que no entiendes y estricto con lo que necesitas. Lo desconocido se ignora, lo opcional se rellena, lo ausente que resulta imprescindible falla de forma explícita y ruidosa. Decidir en qué casilla cae cada campo es un acto de diseño de dominio, no de configuración de biblioteca, y es exactamente el trabajo que ninguna anotación puede hacer por ti.
- Declara un modelo sin tolerancia a claves desconocidas, añade un campo en la respuesta simulada y observa el fallo exacto que produce una versión antigua ante un servidor nuevo.
- Escribe tres variantes del mismo campo —sin valor por defecto, con valor por defecto y anulable— y tabula qué ocurre en cada una ante ausencia, nulo explícito y valor válido.
- Modela una operación de actualización parcial con un envoltorio de tres estados y demuestra que distingue no tocar de borrar.
- Provoca un valor de enumeración desconocido y compara el comportamiento con y sin coerción o repliegue, midiendo qué ve el usuario en cada caso.
- Separa modelos de transferencia y de dominio en un flujo real, y comprueba que un renombrado de campo en el servidor solo obliga a tocar la función de mapeo.