Custom Jackson Serializers & Deserializers in Kotlin
When Jackson's defaults betray your domain types, hand-written JsonSerializer/JsonDeserializer pairs put you back in control of the wire format.
Jackson does a remarkable job auto-mapping Kotlin data classes to JSON, but its defaults stop being helpful the moment you introduce a value-ish wrapper. Consider a `Money` type. Internally you almost certainly want to store an exact integer number of cents to avoid floating-point rounding errors, yet you don't want that leaking onto the wire as a bare `Long` named `cents`. You want clients to send and receive a clean decimal string like `"19.99"`. Jackson's reflection-based mapper has no idea about this contract: left alone it will serialize the public properties it can see, producing something like `{"cents":1999}`. A custom serializer is how you teach Jackson the one thing it cannot infer โ your intended external representation.
The two halves of the contract are `JsonSerializer<T>` (object โ JSON) and `JsonDeserializer<T>` (JSON โ object). You subclass each and override a single method. In `serialize(value, gen, serializers)` you receive a `JsonGenerator`, which is a low-level streaming writer: you call methods like `gen.writeString(...)`, `gen.writeNumber(...)`, or `gen.writeStartObject()` to emit exactly the tokens you want. In `deserialize(parser, ctxt)` you receive a `JsonParser` positioned on the current token; you typically pull the raw value with `parser.text`, `parser.longValue`, or `parser.readValueAsTree()` and reconstruct your domain object, throwing a meaningful exception if the input is malformed. Keeping these two classes side by side in the same file makes the round-trip contract obvious and easy to keep in sync.
For a `Money` wrapper the serializer converts cents to a decimal string, and the deserializer parses that string back into exact cents. The crucial detail is to keep all arithmetic in integer or `BigDecimal` space โ never route money through a `Double`. In the deserializer, build a `BigDecimal` from the incoming text, multiply by 100, and call `movePointRight`/`setScale` with an explicit `RoundingMode` so a value like `"19.995"` fails loudly rather than silently rounding. This is where custom code earns its keep: you encode an invariant (money is always exact) that no annotation or default mapper could enforce for you.
Once the pair exists, you have to register it so Jackson actually uses it. The most surgical option is the `@JsonSerialize(using = ...)` and `@JsonDeserialize(using = ...)` annotations placed directly on a property or on the type itself. Annotating the `Money` class is convenient because the binding then applies everywhere `Money` appears โ request bodies, response bodies, nested fields, list elements โ without repeating yourself. Annotating a single property is the escape hatch for when only one field needs special treatment, or when you cannot edit the target type because it lives in a third-party library. On a Kotlin constructor property, reach for the `@field:` use-site target so Jackson reliably sees the annotation for both serialization and deserialization.
The more powerful and decoupled approach is a `SimpleModule`. You instantiate a module, call `addSerializer(Money::class.java, MoneySerializer())` and `addDeserializer(Money::class.java, MoneyDeserializer())`, then register that module on the `ObjectMapper`. This keeps your domain classes free of Jackson annotations entirely โ a real win for a clean hexagonal architecture where the core model should not know that JSON exists. A single module can carry serializers for many types, so it becomes the one place that declares 'here is how this whole bounded context crosses the wire.'
In a Spring Boot 3 application you rarely build the `ObjectMapper` by hand. Instead you expose your module as a Spring bean: any `com.fasterxml.jackson.databind.Module` bean is auto-detected by Spring Boot and applied to the auto-configured `ObjectMapper` that backs `@RequestBody`/`@ResponseBody`. So you write a `@Bean fun moneyModule(): SimpleModule` (or have your module extend `SimpleModule` and annotate the class with `@Component`) and every controller in the app immediately speaks `Money` correctly. Prefer this over defining your own `ObjectMapper` bean, which would silently discard Boot's many sensible defaults like JavaTimeModule and the Kotlin module.
A few practical guardrails. Register the Kotlin module (`jacksonObjectMapper()` from `jackson-module-kotlin`, or the Spring Boot starter, does this for you) so nullability and primary-constructor parameters are honored. Make your serializers stateless and therefore thread-safe โ the `ObjectMapper` is shared across all request threads, so never stash per-call data in a field. Handle `null` deliberately: Jackson invokes your `serialize`/`deserialize` only for non-null values by default, but you should still guard your deserializer against a `null` or empty token and surface a clear `JsonMappingException`. Finally, write a tiny round-trip test โ serialize, then deserialize, and assert you got an equal object back โ because a serializer and deserializer that disagree are far worse than no customization at all.
Beyond `Money`, this exact pattern covers a huge range of real needs: emitting enums as lowercase API codes, encoding a domain `UserId(value: UUID)` as a bare string, formatting an `Instant` in a specific timezone, or flattening a wrapper so it serializes as a scalar instead of an object. The mental model is always the same โ a serializer says 'this is what my type looks like on the wire,' a deserializer says 'this is how I rebuild my type from the wire,' and a module is the registry that wires both into Jackson without polluting your domain.
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๐ง Check your understanding
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?