JSON polimórfico con Jackson y tipos sellados de Kotlin
Enseña a Jackson a serializar y deserializar tus ADT de clases selladas con un solo campo discriminador de tipo.
El polimorfismo es el punto donde JSON y el sistema de tipos de Kotlin más discrepan. Una `sealed interface` o `sealed class` modela un conjunto cerrado de variantes de forma elegante en el código, pero en la red JSON son solo objetos sin tipo. Cuando serializas un `PaymentMethod` que en realidad es un `CreditCard`, el JSON resultante no tiene ni idea de qué subtipo provino. Al volver a entrar, Jackson ve un tipo abstracto y no puede elegir una clase concreta. La solución es incrustar un pequeño discriminador de tipo en el JSON y decirle a Jackson cómo leerlo. Eso es exactamente lo que hacen `@JsonTypeInfo` y `@JsonSubTypes`.
`@JsonTypeInfo` se coloca sobre el tipo base (la interfaz o clase sellada) y responde dos preguntas: cómo se codifica el tipo y dónde vive. La opción más común y legible es `use = JsonTypeInfo.Id.NAME` junto con `include = JsonTypeInfo.As.PROPERTY` y `property = "type"`. Esto produce un nombre lógico (como `"credit_card"`) almacenado en un campo hermano llamado `type`, en lugar de un nombre de clase Java totalmente cualificado. Usar nombres lógicos en vez de `Id.CLASS` es importante: los nombres de clase filtran la estructura de tus paquetes, se rompen en cuanto renombras o mueves una clase y son un riesgo de seguridad porque permiten que un payload elija qué clase se instancia.
`@JsonSubTypes` es el registro que mapea cada nombre lógico a una subclase concreta. Anotas el tipo base con una lista de entradas `@JsonSubTypes.Type`, cada una emparejando un `value` (la clase Kotlin) con un `name` (la cadena discriminadora). Como los nombres son explícitos, el contrato JSON queda desacoplado de tu código: puedes renombrar `CreditCard` a `CardPayment` en Kotlin sin cambiar un solo byte de la API pública, mientras el `name = "credit_card"` se mantenga. Esta es toda la razón para preferir las anotaciones sobre los valores por defecto basados en reflexión.
Los tipos sellados de Kotlin son el portador ideal para este patrón porque la exhaustividad la impone el compilador, no la esperanza. Una vez que Jackson te devuelve un `PaymentMethod`, una expresión `when` sobre sus subtipos no necesita rama `else`, y añadir una nueva variante convierte a cada consumidor en un error de compilación hasta que se maneje. Combina eso con variantes `data class` y obtienes `equals`, `copy` y un `toString` razonable gratis. Anota los parámetros del constructor de las variantes y recuerda que Jackson sobre Kotlin necesita el módulo `jackson-module-kotlin` registrado para ver tus constructores primarios y propiedades `val`.
Hacer round-trip de un ADT significa que `deserialize(serialize(x)) == x` para cada valor del tipo. Con variantes `data class` esa comprobación de igualdad es real y verificable, lo que la convierte en una invariante excelente para asegurar en una prueba unitaria. El campo discriminador es simétrico: Jackson lo escribe al salir y lo consume al entrar, así que mientras ambos lados compartan la misma configuración del `ObjectMapper`, los valores sobreviven el viaje intactos. Cuidado con dos trampas: un discriminador ausente en el JSON entrante lanza una `InvalidTypeIdException`, y un valor de discriminador desconocido también, salvo que configures un `defaultImpl` como respaldo.
La configuración importa tanto como las anotaciones. Registra el módulo de Kotlin (`jacksonObjectMapper()` de `jackson-module-kotlin` lo hace por ti), y para que se respete la nulabilidad de Kotlin, ese módulo es el que enseña a Jackson que un `val` no nulo es realmente obligatorio. En una aplicación Spring Boot 3 normalmente no construyes el mapper a mano en absoluto; Boot autoconfigura uno y, si `jackson-module-kotlin` está en el classpath, lo registra automáticamente. Aun así puedes personalizarlo con un bean `Jackson2ObjectMapperBuilderCustomizer` para añadir módulos como `JavaTimeModule` o poner `FAIL_ON_UNKNOWN_PROPERTIES` en false para compatibilidad hacia adelante.
También hay una sutileza de ubicación que vale la pena interiorizar. `include = As.PROPERTY` pone el discriminador junto a los campos de datos, que es el formato más amigable para la mayoría de los clientes REST. `As.EXISTING_PROPERTY` es la opción correcta cuando el subtipo ya declara un `val type` que quieres serializar como dato real y como discriminador a la vez, evitando un campo duplicado o fantasma. `As.WRAPPER_OBJECT` anida el payload bajo una clave con el nombre del tipo, que algunos esquemas de eventos prefieren. Elige uno deliberadamente y documéntalo, porque cambiarlo después es un cambio rompedor para todo mensaje almacenado o en tránsito.
Finalmente, este patrón escala naturalmente a event sourcing y colas de mensajes, donde persistes un flujo de eventos heterogéneos como JSON y debes reconstruir sus tipos Kotlin exactos más tarde. Una jerarquía sellada `DomainEvent` con nombres de discriminador estables te da un contrato duradero y versionable: los eventos antiguos siguen deserializándose porque su `name` nunca cambia, y los nuevos tipos de evento se añaden a `@JsonSubTypes` sin perturbar el resto. Mantén los nombres de discriminador en un solo lugar, trátalos como parte de tu esquema público y nunca los derives de nombres de clase que algún día podrías refactorizar.
import com.fasterxml.jackson.annotation.JsonSubTypesimport com.fasterxml.jackson.annotation.JsonTypeInfo@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,include = JsonTypeInfo.As.PROPERTY,property = "type")@JsonSubTypes(JsonSubTypes.Type(value = PaymentMethod.CreditCard::class, name = "credit_card"),JsonSubTypes.Type(value = PaymentMethod.BankTransfer::class, name = "bank_transfer"),JsonSubTypes.Type(value = PaymentMethod.Cash::class, name = "cash"))sealed interface PaymentMethod {data class CreditCard(val last4: String, val brand: String) : PaymentMethoddata class BankTransfer(val iban: String) : PaymentMethoddata object Cash : PaymentMethod}
The core pattern: a sealed interface annotated with @JsonTypeInfo + @JsonSubTypes. The 'type' field is the discriminator; logical names decouple the JSON contract from class names.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport com.fasterxml.jackson.module.kotlin.readValueval mapper = jacksonObjectMapper()val original: PaymentMethod = PaymentMethod.CreditCard(last4 = "4242", brand = "visa")val json = mapper.writeValueAsString(original)// {"type":"credit_card","last4":"4242","brand":"visa"}val restored: PaymentMethod = mapper.readValue(json)check(restored == original) // structural equality from data classwhen (restored) { // exhaustive, no else neededis PaymentMethod.CreditCard -> println("card ****" + restored.last4)is PaymentMethod.BankTransfer -> println("iban " + restored.iban)PaymentMethod.Cash -> println("cash")}
Round-tripping with the Kotlin module. jacksonObjectMapper() pre-registers jackson-module-kotlin so primary-constructor vals and nullability are honored. Note the emitted 'type' field.
import com.fasterxml.jackson.databind.DeserializationFeatureimport org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration@Configurationclass JacksonConfig {@Beanfun jacksonCustomizer() = Jackson2ObjectMapperBuilderCustomizer { builder ->builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)}}// Optional fallback for unrecognized discriminator values:@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = DomainEvent.Unknown::class)sealed interface DomainEvent {data class Unknown(val raw: String? = null) : DomainEvent}
Spring Boot 3: customize the autoconfigured ObjectMapper instead of building your own. defaultImpl gives a safe fallback when an unknown discriminator arrives, aiding forward compatibility.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why is `@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)` with `@JsonSubTypes` preferred over `use = JsonTypeInfo.Id.CLASS` for a sealed hierarchy?