Serializadores y Deserializadores personalizados de Jackson en Kotlin
Cuando los valores por defecto de Jackson traicionan tus tipos de dominio, los pares JsonSerializer/JsonDeserializer escritos a mano te devuelven el control del formato en el cable.
Jackson hace un trabajo notable mapeando automáticamente las data classes de Kotlin a JSON, pero sus valores por defecto dejan de ser útiles en cuanto introduces un envoltorio de tipo valor. Considera un tipo `Money`. Internamente casi seguro quieres almacenar un número entero exacto de centavos para evitar errores de redondeo de punto flotante, pero no quieres que eso se filtre al cable como un simple `Long` llamado `cents`. Quieres que los clientes envíen y reciban una cadena decimal limpia como `"19.99"`. El mapeador basado en reflexión de Jackson no conoce este contrato: por su cuenta serializará las propiedades públicas que ve, produciendo algo como `{"cents":1999}`. Un serializador personalizado es la forma de enseñarle a Jackson lo único que no puede inferir: tu representación externa deseada.
Las dos mitades del contrato son `JsonSerializer<T>` (objeto → JSON) y `JsonDeserializer<T>` (JSON → objeto). Heredas de cada una y sobrescribes un solo método. En `serialize(value, gen, serializers)` recibes un `JsonGenerator`, que es un escritor de streaming de bajo nivel: llamas a métodos como `gen.writeString(...)`, `gen.writeNumber(...)` o `gen.writeStartObject()` para emitir exactamente los tokens que quieres. En `deserialize(parser, ctxt)` recibes un `JsonParser` posicionado en el token actual; típicamente extraes el valor crudo con `parser.text`, `parser.longValue` o `parser.readValueAsTree()` y reconstruyes tu objeto de dominio, lanzando una excepción significativa si la entrada está malformada. Mantener estas dos clases juntas en el mismo archivo hace obvio el contrato de ida y vuelta y fácil de mantener sincronizado.
Para un envoltorio `Money`, el serializador convierte centavos a una cadena decimal, y el deserializador parsea esa cadena de vuelta a centavos exactos. El detalle crucial es mantener toda la aritmética en espacio entero o `BigDecimal`, nunca enrutar dinero a través de un `Double`. En el deserializador, construye un `BigDecimal` desde el texto entrante, multiplica por 100 y llama a `movePointRight`/`setScale` con un `RoundingMode` explícito para que un valor como `"19.995"` falle ruidosamente en lugar de redondear en silencio. Aquí es donde el código personalizado se gana su lugar: codificas un invariante (el dinero siempre es exacto) que ninguna anotación o mapeador por defecto podría imponer por ti.
Una vez que existe el par, debes registrarlo para que Jackson realmente lo use. La opción más quirúrgica son las anotaciones `@JsonSerialize(using = ...)` y `@JsonDeserialize(using = ...)` colocadas directamente sobre una propiedad o sobre el tipo mismo. Anotar la clase `Money` es conveniente porque el vínculo se aplica entonces en todas partes donde aparece `Money` — cuerpos de petición, cuerpos de respuesta, campos anidados, elementos de lista — sin repetirte. Anotar una sola propiedad es la salida de emergencia para cuando solo un campo necesita tratamiento especial, o cuando no puedes editar el tipo objetivo porque vive en una biblioteca de terceros. Sobre una propiedad de constructor de Kotlin, usa el destino de sitio de uso `@field:` para que Jackson vea la anotación de forma fiable tanto en la serialización como en la deserialización.
El enfoque más potente y desacoplado es un `SimpleModule`. Instancias un módulo, llamas a `addSerializer(Money::class.java, MoneySerializer())` y `addDeserializer(Money::class.java, MoneyDeserializer())`, y luego registras ese módulo en el `ObjectMapper`. Esto mantiene tus clases de dominio completamente libres de anotaciones de Jackson — una verdadera victoria para una arquitectura hexagonal limpia donde el modelo central no debería saber que el JSON existe. Un solo módulo puede llevar serializadores para muchos tipos, así que se convierte en el único lugar que declara 'así es como todo este contexto delimitado cruza el cable.'
En una aplicación Spring Boot 3 rara vez construyes el `ObjectMapper` a mano. En su lugar expones tu módulo como un bean de Spring: cualquier bean `com.fasterxml.jackson.databind.Module` es autodetectado por Spring Boot y aplicado al `ObjectMapper` autoconfigurado que respalda `@RequestBody`/`@ResponseBody`. Así que escribes un `@Bean fun moneyModule(): SimpleModule` (o haces que tu módulo extienda `SimpleModule` y anotas la clase con `@Component`) y cada controlador en la app habla `Money` correctamente de inmediato. Prefiere esto sobre definir tu propio bean `ObjectMapper`, que descartaría en silencio los muchos valores por defecto sensatos de Boot como JavaTimeModule y el módulo de Kotlin.
Algunas salvaguardas prácticas. Registra el módulo de Kotlin (`jacksonObjectMapper()` de `jackson-module-kotlin`, o el starter de Spring Boot, lo hace por ti) para que la nulabilidad y los parámetros del constructor primario sean respetados. Haz tus serializadores sin estado y por lo tanto seguros para hilos — el `ObjectMapper` se comparte entre todos los hilos de petición, así que nunca guardes datos por llamada en un campo. Maneja `null` deliberadamente: Jackson invoca tu `serialize`/`deserialize` solo para valores no nulos por defecto, pero aún debes proteger tu deserializador contra un token `null` o vacío y exponer un `JsonMappingException` claro. Finalmente, escribe una pequeña prueba de ida y vuelta — serializa, luego deserializa, y verifica que obtuviste un objeto igual de vuelta — porque un serializador y deserializador que no concuerdan son mucho peores que ninguna personalización.
Más allá de `Money`, este patrón exacto cubre un enorme rango de necesidades reales: emitir enums como códigos de API en minúsculas, codificar un `UserId(value: UUID)` de dominio como una simple cadena, formatear un `Instant` en una zona horaria específica, o aplanar un envoltorio para que se serialice como escalar en lugar de objeto. El modelo mental siempre es el mismo — un serializador dice 'así es como se ve mi tipo en el cable,' un deserializador dice 'así reconstruyo mi tipo desde el cable,' y un módulo es el registro que conecta ambos a Jackson sin contaminar tu dominio.
import com.fasterxml.jackson.core.JsonGeneratorimport com.fasterxml.jackson.core.JsonParserimport com.fasterxml.jackson.databind.*import java.math.BigDecimalimport java.math.RoundingModedata class Money(val cents: Long) {fun toDecimal(): BigDecimal = BigDecimal(cents).movePointLeft(2)companion object {fun parse(text: String): Money {val scaled = BigDecimal(text).movePointRight(2)// setScale(0) with no RoundingMode throws if there are leftover fractional centsreturn Money(scaled.setScale(0).longValueExact())}}}class MoneySerializer : JsonSerializer<Money>() {override fun serialize(value: Money, gen: JsonGenerator, serializers: SerializerProvider) {gen.writeString(value.toDecimal().setScale(2, RoundingMode.UNNECESSARY).toPlainString())}}class MoneyDeserializer : JsonDeserializer<Money>() {override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Money {val text = p.text?.trim()if (text.isNullOrEmpty()) throw JsonMappingException.from(p, "Money must be a non-empty decimal string")return try {Money.parse(text)} catch (e: ArithmeticException) {throw JsonMappingException.from(p, "Invalid money amount: '$text'", e)}}}
The Money wrapper plus its serializer/deserializer pair. Money is stored as exact cents; on the wire it is a decimal string like "19.99". A plain data class (not a @JvmInline value class) is used because Kotlin value classes are unboxed at runtime and do not bind reliably to custom Jackson serializers. All conversion stays in BigDecimal/Long space so rounding is explicit and never silent.
import com.fasterxml.jackson.databind.annotation.JsonDeserializeimport com.fasterxml.jackson.databind.annotation.JsonSerialize@JsonSerialize(using = MoneySerializer::class)@JsonDeserialize(using = MoneyDeserializer::class)data class Money(val cents: Long)// Or annotate just one property when you cannot touch the type:data class Invoice(val id: String,@field:JsonSerialize(using = MoneySerializer::class)@field:JsonDeserialize(using = MoneyDeserializer::class)val total: Money,)
Registration option A: annotate the type directly. The binding now applies everywhere Money appears, and the domain class needs no other Jackson knowledge. When annotating a single Kotlin constructor property instead, use the @field: use-site target so Jackson sees it for both directions.
import com.fasterxml.jackson.databind.module.SimpleModuleimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration@Configurationclass JacksonConfig {@Beanfun domainModule(): SimpleModule = SimpleModule("domain").apply {addSerializer(Money::class.java, MoneySerializer())addDeserializer(Money::class.java, MoneyDeserializer())// one module can carry many type bindings for the whole bounded context}}
Registration option B (preferred): a SimpleModule exposed as a Spring bean. Spring Boot auto-detects any Module bean and applies it to the auto-configured ObjectMapper, so every controller speaks Money without annotating the domain model. Do NOT define your own ObjectMapper bean for this.
import java.math.BigDecimalfun centsFromText(text: String): Long =BigDecimal(text).movePointRight(2).setScale(0).longValueExact()fun textFromCents(cents: Long): String =BigDecimal(cents).movePointLeft(2).setScale(2).toPlainString()fun main() {val original = "19.99"val cents = centsFromText(original) // 1999val roundTripped = textFromCents(cents) // "19.99"println("$original -> $cents -> $roundTripped")check(roundTripped == original) { "round trip must be lossless" }try {centsFromText("19.995") // fractional cent -> must failerror("should have rejected 19.995")} catch (e: ArithmeticException) {println("correctly rejected 19.995")}}
A self-contained round-trip check using only the Kotlin stdlib's BigDecimal math (the same logic Money.parse / toDecimal rely on). This runs on plain Kotlin with no Jackson or Spring on the classpath.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. In a Spring Boot 3 app, what is the recommended way to make a custom MoneySerializer/MoneyDeserializer apply to all @RequestBody and @ResponseBody payloads without annotating your domain classes?