Jackson in Spring WebFlux
In WebFlux, Jackson is the invisible engine that turns JSON into your data classes and back โ until you need to bend it, and then you reach for one ObjectMapper bean.
Spring WebFlux is the reactive, non-blocking web stack built on Project Reactor, and Jackson is the JSON library doing the heavy lifting underneath it. When a request arrives with a JSON body, WebFlux does not hand you a raw string โ it asks a registered codec, the Jackson2JsonDecoder, to read the reactive byte stream and materialize it into your Kotlin types. On the way out, the Jackson2JsonEncoder serializes your return value (a single object, a Flux, or a Mono) back into JSON bytes. Because Spring Boot auto-configures all of this, the typical controller never mentions Jackson at all; you declare a function that returns a data class and the framework wires the (de)serialization for you.
With the kotlin-jackson workflow, the key dependency is com.fasterxml.jackson.module:jackson-module-kotlin (pulled in automatically by spring-boot-starter-webflux through the Kotlin starter). This module teaches Jackson how to read Kotlin data classes: it resolves constructor parameter names, respects non-nullable types, and honors default values so you do not need a no-arg constructor or lateinit hacks. Without it, Jackson cannot see Kotlin constructor parameter names and deserialization of immutable data classes fails. The module is what makes idiomatic Kotlin โ val properties, primary constructors, defaults โ line up cleanly with incoming JSON.
In coroutine-based controllers you read the body with the suspending extension awaitBody<T>(). A ServerRequest exposes bodyToMono<T>() for the reactive style, but awaitBody<T>() suspends the coroutine until the full body is decoded and returns a plain T, which reads like ordinary sequential code. The reified type parameter is important: awaitBody<CreateUserRequest>() carries the full generic type at runtime, so Jackson can correctly deserialize even generic shapes like List<OrderLine>. If the body might be absent, awaitBodyOrNull<T>() returns null instead of throwing. These extensions live in org.springframework.web.reactive.function.server and pair naturally with suspend handler functions.
Most of the time the defaults are fine, but real systems need to configure how JSON looks and parses. The cleanest hook in a Spring Boot app is a Jackson2ObjectMapperBuilderCustomizer bean: Boot builds the ObjectMapper for you and applies every customizer, so your tweaks are merged with Spring's sensible defaults (the Kotlin module, the JavaTimeModule for java.time, and disabling timestamp dates). Inside a customizer you can register modules, set a property naming strategy like snake_case, ignore unknown properties, or control null inclusion. Reach for a full @Bean fun objectMapper(): ObjectMapper only when you genuinely need to replace the mapper wholesale โ and remember WebFlux discovers it through CodecCustomizer wiring, not the MVC message-converter path.
A subtle but vital point: the JSON codecs WebFlux uses are not automatically the same instance as a bean you define unless Boot's auto-configuration picks it up. With Spring Boot's defaults and a customizer, the same configured ObjectMapper backs both the encoder and the decoder, so your naming strategy and module registrations apply uniformly to request bodies and responses. If you construct your own ObjectMapper by hand and need it in the codecs, register it through a CodecCustomizer or a WebFluxConfigurer overriding configureHttpMessageCodecs. Sticking with the customizer approach avoids this footgun entirely.
Streaming JSON is where WebFlux truly shines. Returning a Flux<T> from a handler lets the framework serialize and flush elements as they are produced, instead of buffering the whole collection in memory. If the client accepts application/json, Spring emits a single JSON array, streaming each element as it arrives. If you instead produce application/x-ndjson (newline-delimited JSON) or text/event-stream, each item is encoded and flushed independently โ ideal for long-lived feeds, large exports, or server-sent events. Jackson encodes each element on demand, so a million-row export never lives entirely in the heap.
Error handling and validation deserve a word. When Jackson cannot map a body โ a malformed payload, a missing non-nullable field, or a type mismatch โ WebFlux raises a ServerWebInputException (often wrapping a DecodingException), which surfaces as an HTTP 400 rather than a 500. You can catch these in a @ControllerAdvice with @ExceptionHandler or, in the functional model, in your router's filter. Because the Kotlin module enforces nullability, a JSON object missing a required field is rejected at deserialization time, giving you a strong, declarative contract: if it deserialized, your non-null Kotlin fields really are populated.
Put together, the mental model is simple. Spring Boot auto-configures one Jackson ObjectMapper, shares it across the WebFlux encoder and decoder, and exposes it for customization through Jackson2ObjectMapperBuilderCustomizer. Your controllers stay clean โ awaitBody<T>() in, a data class or Flux out โ while the framework streams bytes reactively. You only touch Jackson directly when you need a custom naming strategy, a serializer, or streaming media types, and even then the change lives in one place. Master that single configuration point and the rest of WebFlux JSON handling becomes predictable.
data class CreateUserRequest(val name: String, val email: String)data class UserResponse(val id: Long, val name: String, val email: String)class UserHandler(private val service: UserService) {suspend fun create(request: ServerRequest): ServerResponse {// Suspends until the full body is decoded; reified T drives Jacksonval body = request.awaitBody<CreateUserRequest>()val user = service.create(body.name, body.email)return ServerResponse.status(HttpStatus.CREATED).bodyValueAndAwait(UserResponse(user.id, user.name, user.email))}}
A coroutine handler in the functional model: awaitBody<T>() decodes the JSON body into a Kotlin data class, and the returned object is serialized back to JSON automatically. The Kotlin module makes the immutable data class work without a no-arg constructor.
@Configurationclass JacksonConfig {@Beanfun jacksonCustomizer(): Jackson2ObjectMapperBuilderCustomizer =Jackson2ObjectMapperBuilderCustomizer { builder ->builder.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)builder.serializationInclusion(JsonInclude.Include.NON_NULL)// KotlinModule + JavaTimeModule are added by Boot automatically}}
Configure the single auto-configured ObjectMapper for the whole app via a customizer bean. These settings apply to BOTH the WebFlux encoder and decoder, so requests and responses use snake_case and tolerate unknown fields.
@RestControllerclass MetricsController(private val repo: MetricRepository) {// Single streamed JSON array for normal clients@GetMapping("/metrics", produces = [MediaType.APPLICATION_JSON_VALUE])fun metrics(): Flux<Metric> = repo.findAllReactive()// Newline-delimited JSON: one object per line, flushed incrementally@GetMapping("/metrics/stream", produces = [MediaType.APPLICATION_NDJSON_VALUE])fun stream(): Flux<Metric> = repo.findAllReactive()}
Streaming JSON: returning a Flux lets WebFlux serialize and flush each element as it is produced. With application/x-ndjson each item is an independent line, so huge exports never buffer fully in memory.
import kotlinx.coroutines.runBlockingdata class CreateUserRequest(val name: String, val email: String)// Stand-in for Jackson + the Kotlin module: required non-null fields must be presentfun decode(json: Map<String, String?>): CreateUserRequest {val name = json["name"] ?: error("missing required field: name")val email = json["email"] ?: error("missing required field: email")return CreateUserRequest(name, email)}fun main() = runBlocking {val ok = decode(mapOf("name" to "Ada", "email" to "ada@example.com"))println("decoded: " + ok)val result = runCatching { decode(mapOf("name" to "Ada")) }println("missing email -> " + result.exceptionOrNull()?.message)}
The Kotlin-module mental model, shown with plain coroutines and no Spring: nullability is part of the deserialization contract. This compiles and runs on kotlin stdlib + kotlinx.coroutines, simulating how a missing required field becomes an error.
Arena IDE๐ง Check your understanding
0/1 ยท 0/1 answered1. In a coroutine WebFlux handler, why is awaitBody<CreateUserRequest>() preferred over manually parsing the request as a String?