JSON in Kotlin Spring Boot: Data Classes, Jackson Module, @RequestBody & Serialization

Last updated:

Kotlin Spring Boot serializes data classes to JSON automatically — data class User(val name: String, val email: String) returned from a @RestController method produces a Content-Type: application/json response with zero configuration, using Jackson's Kotlin module to handle Kotlin-specific class features. The jackson-module-kotlin dependency is required: it enables proper handling of Kotlin data class constructors (which lack a no-arg constructor by default), nullable types (String? becomes JSON null), and default parameter values. @RequestBody deserializes JSON into a Kotlin data class with the same zero-config behavior. @JsonProperty("user_name") overrides a property's JSON key; @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) converts all properties to snake_case. Kotlin sealed classes model discriminated union JSON responses with @JsonTypeInfo. This guide covers Jackson Kotlin module setup, data class serialization, @RequestBody deserialization, nullable type handling, naming strategies, sealed class polymorphism, and Spring Boot validation with @Valid.

Kotlin Data Classes and Jackson Auto-Serialization

A Kotlin data class returned from a @RestController method serializes to JSON automatically — Jackson maps each val or var property to a JSON key using the property name. No @JsonSerialize annotation, no manual mapping, no boilerplate. The result is a Content-Type: application/json response body matching the data class structure exactly.

// build.gradle.kts — required dependencies
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

// ── Data class — zero annotation required ─────────────────────
data class User(
    val id: Long,
    val name: String,
    val email: String,
    val age: Int,
)

// ── Controller — returns data class directly ───────────────────
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/users")
class UserController {

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): User {
        // Jackson serializes this data class automatically
        return User(id = id, name = "Alice", email = "alice@example.com", age = 30)
    }
}

// HTTP response:
// HTTP/1.1 200 OK
// Content-Type: application/json
// {
//   "id": 1,
//   "name": "Alice",
//   "email": "alice@example.com",
//   "age": 30
// }

// ── Nested data classes — also serialized automatically ────────
data class Address(
    val street: String,
    val city: String,
    val country: String,
)

data class UserWithAddress(
    val id: Long,
    val name: String,
    val address: Address,       // nested — becomes a nested JSON object
    val tags: List<String>,     // List<T> becomes a JSON array
)

// Returns:
// {
//   "id": 1,
//   "name": "Alice",
//   "address": { "street": "123 Main St", "city": "Springfield", "country": "US" },
//   "tags": ["admin", "verified"]
// }

Jackson reads Kotlin data class properties via the jackson-module-kotlin extension, which uses Kotlin reflection (kotlin-reflect) to inspect the primary constructor. Without this module, Jackson falls back to Java reflection and fails because Kotlin data classes do not have a zero-argument constructor by default — you'd see an InvalidDefinitionException: No suitable constructor found at runtime. Spring Boot 2.3+ auto-registers the module when it is on the classpath, so no explicit @Bean ObjectMapper configuration is needed.

Setting Up the Jackson Kotlin Module

The Jackson Kotlin module must be added explicitly to your Gradle or Maven build file — spring-boot-starter-web does not include it automatically. Spring Boot's dependency management BOM aligns the module version with your Jackson version, so you don't need to specify a version when using spring-boot-starter-parent or the Spring Boot Gradle plugin.

// ── build.gradle.kts (Kotlin DSL) ─────────────────────────────
plugins {
    id("org.springframework.boot") version "3.3.0"
    id("io.spring.dependency-management") version "1.1.5"
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.spring") version "2.0.0"   // makes Spring proxies work with Kotlin
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Required: Jackson Kotlin module — version managed by Spring Boot BOM
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

    // Required: Kotlin reflection — used by the Kotlin module
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // Optional: validation support for @Valid on @RequestBody
    implementation("org.springframework.boot:spring-boot-starter-validation")
}

// ── Maven pom.xml equivalent ───────────────────────────────────
// <dependency>
//   <groupId>com.fasterxml.jackson.module</groupId>
//   <artifactId>jackson-module-kotlin</artifactId>
//   <!-- version managed by spring-boot-dependencies BOM -->
// </dependency>
// <dependency>
//   <groupId>org.jetbrains.kotlin</groupId>
//   <artifactId>kotlin-reflect</artifactId>
// </dependency>

// ── Spring Boot auto-configuration (no manual setup needed) ────
// Spring Boot 2.3+ auto-registers KotlinModule when it is on the classpath.
// You do NOT need to declare a @Bean ObjectMapper for the module to work.

// ── Manual registration (if not using Spring Boot auto-config) ─
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class JacksonConfig {
    @Bean
    fun objectMapper(): ObjectMapper = ObjectMapper().apply {
        registerKotlinModule()
        // additional configuration:
        // configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        // setSerializationInclusion(JsonInclude.Include.NON_NULL)
    }
}

// ── Verify the module is active ────────────────────────────────
// Add this to a test or startup bean to confirm registration:
@Component
class JacksonModuleCheck(private val mapper: ObjectMapper) : CommandLineRunner {
    override fun run(vararg args: String?) {
        val registered = mapper.registeredModuleIds
        check("com.fasterxml.jackson.module.kotlin.KotlinModule" in registered.map { it.toString() }) {
            "jackson-module-kotlin is NOT registered — add the dependency"
        }
    }
}

The kotlin("plugin.spring") Gradle plugin is also important: by default, Kotlin classes are final, which prevents Spring from creating CGLIB subclass proxies for @Transactional and @Cacheable. The plugin automatically adds open to Spring-annotated classes. Without it, @Service beans with @Transactional methods throw BeanCreationException at startup.

@RequestBody Deserialization with Data Classes

@RequestBody in Kotlin Spring Boot works identically to Java — Jackson deserializes the incoming JSON into a Kotlin data class using the primary constructor. The jackson-module-kotlin module maps JSON keys to constructor parameters by name, handling missing keys (via Kotlin default values), extra keys (ignored by default), and type coercion automatically.

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

// ── Request body data class ────────────────────────────────────
data class CreateUserRequest(
    val name: String,
    val email: String,
    val age: Int,
    val role: String = "user",      // default value — JSON key is optional
)

data class UpdateUserRequest(
    val name: String? = null,       // all nullable with defaults for PATCH
    val email: String? = null,
    val age: Int? = null,
)

// ── Controller ─────────────────────────────────────────────────
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {

    // POST /api/users — full create
    @PostMapping
    fun createUser(@RequestBody req: CreateUserRequest): ResponseEntity<User> {
        // req.name, req.email, req.age are populated from JSON
        // req.role defaults to "user" if the JSON key "role" is absent
        val user = userService.create(req)
        return ResponseEntity.status(HttpStatus.CREATED).body(user)
    }

    // PATCH /api/users/{id} — partial update
    @PatchMapping("/{id}")
    fun updateUser(
        @PathVariable id: Long,
        @RequestBody req: UpdateUserRequest,
    ): User {
        // Only non-null fields are updated — missing JSON keys stay null
        return userService.update(id, req)
    }
}

// ── Incoming JSON → deserialization behavior ───────────────────
// POST body:  { "name": "Alice", "email": "alice@example.com", "age": 30 }
// Result: CreateUserRequest(name="Alice", email="alice@example.com", age=30, role="user")
// "role" absent → uses Kotlin default "user"

// PATCH body: { "name": "Bob" }
// Result: UpdateUserRequest(name="Bob", email=null, age=null)
// Missing keys → null (declared with = null defaults)

// ── Handle unknown JSON keys ────────────────────────────────────
// By default, extra JSON keys (not in the data class) throw:
// HttpMessageNotReadableException: Unrecognized field "unknownField"
// To ignore unknown keys globally:
// application.properties: spring.jackson.deserialization.fail-on-unknown-properties=false
// Or on the data class:
// @JsonIgnoreProperties(ignoreUnknown = true)
// data class CreateUserRequest(...)

// ── Reading raw JSON body when needed ──────────────────────────
@PostMapping("/raw")
fun rawBody(@RequestBody body: Map<String, Any?>): Map<String, Any?> {
    // Jackson deserializes any JSON object to Map<String, Any?>
    // Use this for fully dynamic payloads
    return body
}

// ── Returning ResponseEntity with status codes ─────────────────
@PostMapping("/v2")
fun createUserV2(@RequestBody req: CreateUserRequest): ResponseEntity<ApiResponse<User>> {
    val user = userService.create(req)
    return ResponseEntity.status(201).body(
        ApiResponse(success = true, data = user)
    )
}

data class ApiResponse<T>(val success: Boolean, val data: T)

When a required constructor parameter (no default value, non-nullable) is missing from the JSON body, jackson-module-kotlin throws a MissingKotlinParameterException, which Spring translates to HTTP 400. This is stricter than Java, where a missing field silently sets the field to null or 0. Kotlin's strict null safety at the data class level catches missing required fields at deserialization time rather than later in the service layer.

Nullable Types and JSON null in Kotlin

Kotlin's type system distinguishes nullable (String?) from non-nullable (String) at the language level. Jackson's Kotlin module respects this distinction: a nullable property serializes JSON null when the value is null, and a missing JSON key for a non-nullable property without a default triggers an error during deserialization.

import com.fasterxml.jackson.annotation.JsonInclude

// ── Nullable type serialization ────────────────────────────────
data class UserProfile(
    val id: Long,
    val name: String,           // non-nullable — always present in JSON
    val bio: String?,           // nullable — serializes as "bio":null or "bio":"text"
    val avatarUrl: String?,     // nullable — may be null
    val age: Int?,              // nullable integer
)

// userProfile with bio=null:
// { "id": 1, "name": "Alice", "bio": null, "avatarUrl": null, "age": null }

// ── Suppress null fields with @JsonInclude ─────────────────────
@JsonInclude(JsonInclude.Include.NON_NULL)
data class UserProfileCompact(
    val id: Long,
    val name: String,
    val bio: String?,           // omitted from JSON when null
    val avatarUrl: String?,     // omitted from JSON when null
)

// userProfileCompact with bio=null:
// { "id": 1, "name": "Alice" }
// bio and avatarUrl omitted because they are null

// ── Global NON_NULL via application.properties ─────────────────
// spring.jackson.default-property-inclusion=non_null
// Applies to all controllers — avoid if some APIs need explicit nulls

// ── Nullable deserialization behavior ──────────────────────────
data class CreateRequest(
    val name: String,             // required — error if missing from JSON
    val description: String?,     // nullable — null if missing or JSON null
    val count: Int = 0,           // non-nullable with default — 0 if missing
    val label: String? = null,    // nullable with default — null if missing
)

// JSON: { "name": "Test" }
// → CreateRequest(name="Test", description=null, count=0, label=null)

// JSON: { "name": "Test", "description": null }
// → CreateRequest(name="Test", description=null, count=0, label=null)

// JSON: {} (missing "name")
// → MissingKotlinParameterException: Parameter specified as non-null is null
//   (Spring returns HTTP 400)

// ── Nullable type in service layer ────────────────────────────
fun buildResponse(user: UserProfile): Map<String, Any?> {
    return buildMap {
        put("id", user.id)
        put("name", user.name)
        // Kotlin safe call — only include bio if it's not null
        user.bio?.let { put("bio", it) }
    }
}

The distinction between "field is absent" and "field is JSON null" matters for PATCH semantics. If both absent and null mean "no change", declare the field as String? = null — both map to Kotlin null. If you need to distinguish "client sent null to clear the field" from "client didn't send the field at all", use Optional<String?> or a custom sealed class wrapper, since Kotlin null cannot represent the absence of a key versus an explicit null value.

JSON Naming Strategies: @JsonProperty and SnakeCaseStrategy

By default, Jackson uses the Kotlin property name as the JSON key — val firstName: String produces "firstName" in JSON. Two mechanisms override this: @JsonProperty for individual fields and @JsonNaming for an entire class. A global naming strategy via application.properties applies to all serialized classes in the application.

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming

// ── @JsonProperty — rename individual fields ───────────────────
data class UserDto(
    @JsonProperty("user_id")       // JSON key is "user_id", Kotlin name is id
    val id: Long,

    @JsonProperty("full_name")
    val name: String,

    val email: String,             // no annotation — JSON key is "email"

    @JsonProperty("created_at")
    val createdAt: String,
)

// Serializes to:
// { "user_id": 1, "full_name": "Alice", "email": "alice@example.com", "created_at": "2026-05-28" }

// Also works for deserialization — JSON key "user_id" maps to Kotlin property id

// ── @JsonNaming — rename all fields in a class ─────────────────
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class OrderResponse(
    val orderId: Long,         // → "order_id"
    val totalAmount: Double,   // → "total_amount"
    val itemCount: Int,        // → "item_count"
    val createdAt: String,     // → "created_at"
    val isPaid: Boolean,       // → "is_paid"
)

// Serializes to:
// {
//   "order_id": 100,
//   "total_amount": 49.99,
//   "item_count": 3,
//   "created_at": "2026-05-28",
//   "is_paid": true
// }

// ── Mix: @JsonNaming + @JsonProperty override ──────────────────
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class MixedNaming(
    val firstName: String,                   // → "first_name" (from @JsonNaming)
    @JsonProperty("surname")                 // explicit override — "surname" not "last_name"
    val lastName: String,
    val emailAddress: String,                // → "email_address" (from @JsonNaming)
)

// ── Global snake_case via application.properties ───────────────
// spring.jackson.property-naming-strategy=SNAKE_CASE
// Applies to ALL controllers — no per-class annotation needed

// ── Available naming strategies ────────────────────────────────
// PropertyNamingStrategies.SNAKE_CASE    → camelCase → snake_case
// PropertyNamingStrategies.LOWER_CAMEL_CASE → default (no change)
// PropertyNamingStrategies.UPPER_CAMEL_CASE → camelCase → PascalCase
// PropertyNamingStrategies.KEBAB_CASE    → camelCase → kebab-case
// PropertyNamingStrategies.LOWER_DOT_CASE → camelCase → lower.dot.case

// ── @JsonAlias — accept multiple JSON key names for deserialization ─
data class FlexibleRequest(
    @JsonAlias("user_name", "userName", "name")  // accepts any of these 3 keys
    val username: String,
)

The global spring.jackson.property-naming-strategy=SNAKE_CASE property is convenient but applies to every endpoint in the application — be careful if some controllers already serve camelCase JSON to existing clients. In those cases, prefer @JsonNaming on specific data classes or use separate ObjectMapper beans per controller if you need mixed naming strategies.

Sealed Classes for Polymorphic JSON Responses

Kotlin sealed classes model discriminated union JSON — an API response that can be one of several types, identified by a discriminant field. Jackson's @JsonTypeInfo adds the type discriminant to the JSON, and @JsonSubTypes maps type names to sealed subclasses. This pattern avoids nullable field hacks and produces clean, self-describing JSON.

import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo

// ── Sealed class hierarchy ─────────────────────────────────────
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type",              // JSON field that carries the type name
)
@JsonSubTypes(
    JsonSubTypes.Type(value = ApiResult.Success::class, name = "success"),
    JsonSubTypes.Type(value = ApiResult.Error::class,   name = "error"),
    JsonSubTypes.Type(value = ApiResult.Pending::class, name = "pending"),
)
sealed class ApiResult {
    data class Success(val data: Any, val message: String = "OK") : ApiResult()
    data class Error(val code: Int, val message: String, val details: List<String> = emptyList()) : ApiResult()
    data class Pending(val jobId: String, val estimatedSeconds: Int) : ApiResult()
}

// ── Controller returning a sealed class ────────────────────────
@RestController
@RequestMapping("/api/jobs")
class JobController(private val jobService: JobService) {

    @PostMapping
    fun submitJob(@RequestBody req: JobRequest): ApiResult {
        return try {
            val jobId = jobService.submit(req)
            ApiResult.Pending(jobId = jobId, estimatedSeconds = 30)
        } catch (e: ValidationException) {
            ApiResult.Error(code = 400, message = e.message ?: "Invalid request")
        }
    }

    @GetMapping("/{jobId}")
    fun getJobResult(@PathVariable jobId: String): ApiResult {
        return when (val result = jobService.getResult(jobId)) {
            null -> ApiResult.Pending(jobId = jobId, estimatedSeconds = 10)
            else -> ApiResult.Success(data = result)
        }
    }
}

// ── JSON output examples ───────────────────────────────────────
// ApiResult.Pending:
// { "type": "pending", "jobId": "abc-123", "estimatedSeconds": 30 }

// ApiResult.Success:
// { "type": "success", "data": { ... }, "message": "OK" }

// ApiResult.Error:
// { "type": "error", "code": 400, "message": "Invalid request", "details": [] }

// ── More complex: sealed class for payment events ──────────────
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "event")
@JsonSubTypes(
    JsonSubTypes.Type(value = PaymentEvent.Authorized::class, name = "authorized"),
    JsonSubTypes.Type(value = PaymentEvent.Captured::class,   name = "captured"),
    JsonSubTypes.Type(value = PaymentEvent.Refunded::class,   name = "refunded"),
    JsonSubTypes.Type(value = PaymentEvent.Failed::class,     name = "failed"),
)
sealed class PaymentEvent {
    abstract val paymentId: String
    abstract val timestamp: String

    data class Authorized(
        override val paymentId: String,
        override val timestamp: String,
        val amount: Double,
        val currency: String,
    ) : PaymentEvent()

    data class Captured(
        override val paymentId: String,
        override val timestamp: String,
        val capturedAmount: Double,
    ) : PaymentEvent()

    data class Refunded(
        override val paymentId: String,
        override val timestamp: String,
        val refundedAmount: Double,
        val reason: String,
    ) : PaymentEvent()

    data class Failed(
        override val paymentId: String,
        override val timestamp: String,
        val errorCode: String,
        val errorMessage: String,
    ) : PaymentEvent()
}

Sealed classes also work for @RequestBody deserialization — Jackson reads the "type" field and constructs the matching subclass. The when expression in Kotlin is exhaustive over sealed classes, so the compiler enforces that you handle every subtype in your service logic, preventing unhandled variant bugs. This makes sealed class polymorphism safer than Java's open inheritance hierarchies with Jackson's @JsonSubTypes.

Validating @RequestBody with @Valid and Bean Validation

Spring Boot validation with Kotlin data classes requires the spring-boot-starter-validation dependency and the @field: annotation target prefix. Without @field:, Kotlin applies the annotation to the constructor parameter rather than the backing field, and Bean Validation ignores it silently. Adding @Valid to the @RequestBody parameter enables automatic validation before the controller method runs.

// ── Required dependency ────────────────────────────────────────
// implementation("org.springframework.boot:spring-boot-starter-validation")

import jakarta.validation.Valid
import jakarta.validation.constraints.*

// ── Validated data class — use @field: prefix in Kotlin ────────
data class CreateUserRequest(
    @field:NotBlank(message = "Name must not be blank")
    @field:Size(min = 1, max = 100, message = "Name must be 1–100 characters")
    val name: String,

    @field:NotBlank(message = "Email must not be blank")
    @field:Email(message = "Email must be a valid address")
    val email: String,

    @field:Min(value = 0, message = "Age must be 0 or greater")
    @field:Max(value = 150, message = "Age must be 150 or less")
    val age: Int,

    @field:Pattern(regexp = "^(user|admin|moderator)$", message = "Role must be user, admin, or moderator")
    val role: String = "user",
)

// ── Nested validated object — use @field:Valid ─────────────────
data class OrderRequest(
    @field:NotNull
    @field:Valid                              // triggers nested validation
    val address: AddressRequest,

    @field:NotEmpty(message = "Order must have at least 1 item")
    val items: List<@Valid OrderItemRequest>,
)

data class AddressRequest(
    @field:NotBlank val street: String,
    @field:NotBlank val city: String,
    @field:Size(min = 2, max = 2) val countryCode: String,
)

data class OrderItemRequest(
    @field:NotBlank val productId: String,
    @field:Min(1) val quantity: Int,
)

// ── Controller with @Valid ─────────────────────────────────────
@RestController
@RequestMapping("/api/users")
class UserController {

    @PostMapping
    fun createUser(@Valid @RequestBody req: CreateUserRequest): ResponseEntity<User> {
        // Reached only if all validation passes
        val user = User(id = 1L, name = req.name, email = req.email, age = req.age)
        return ResponseEntity.status(201).body(user)
    }
}

// ── Global exception handler for validation errors ─────────────
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    @ResponseStatus(org.springframework.http.HttpStatus.BAD_REQUEST)
    fun handleValidationErrors(ex: MethodArgumentNotValidException): ValidationErrorResponse {
        val fieldErrors = ex.bindingResult.fieldErrors
            .groupBy(FieldError::getField)
            .mapValues { (_, errors) -> errors.map { it.defaultMessage ?: "Invalid" } }

        return ValidationErrorResponse(
            success = false,
            errors = fieldErrors,
        )
    }
}

data class ValidationErrorResponse(
    val success: Boolean,
    val errors: Map<String, List<String>>,
)

// ── HTTP 400 response body ─────────────────────────────────────
// {
//   "success": false,
//   "errors": {
//     "name": ["Name must not be blank"],
//     "email": ["Email must be a valid address"],
//     "age": ["Age must be 0 or greater"]
//   }
// }

The @field: annotation target in Kotlin is essential — without it, constraints like @NotBlank are silently applied to the wrong element and validation never runs. Spring's MethodArgumentNotValidException provides access to all field errors via ex.bindingResult.fieldErrors — not just the first — allowing a single HTTP 400 response to report every invalid field at once rather than forcing the client to fix errors one by one.

Key Terms

data class
A Kotlin class declared with the data keyword that automatically generates equals(), hashCode(), toString(), and copy() implementations based on its primary constructor parameters. In Spring Boot JSON contexts, data classes replace Java POJOs as the serialization target — Jackson with jackson-module-kotlin maps each constructor parameter to a JSON field. Data classes must have at least one parameter in their primary constructor; all parameters can declare default values to make them optional during deserialization. The compiler enforces immutability when all parameters are declared with val, making data classes safe for concurrent request handling.
jackson-module-kotlin
A Jackson extension module that adds support for Kotlin-specific class features that standard Java Jackson cannot handle. The module uses Kotlin reflection to read primary constructor parameters (bypassing the need for a no-arg constructor), maps JSON fields to constructor arguments by name, handles nullable types (String?) correctly as JSON null, and respects Kotlin default parameter values during deserialization. It also serializes Kotlin data classes correctly, including val-only properties that Java Jackson would consider read-only and exclude. Required for any Kotlin Spring Boot project that uses @RequestBody or @ResponseBody with data classes. Spring Boot 2.3+ auto-configures it when it is on the classpath.
@JsonTypeInfo
A Jackson annotation that configures polymorphic type handling for serialization and deserialization. When applied to a sealed class or abstract class, it instructs Jackson to include type identification information in the JSON output and use it to select the correct subclass during deserialization. The use = JsonTypeInfo.Id.NAME parameter uses a string type name (rather than a class name), and property = "type" specifies the JSON field that carries the type discriminant. Combined with @JsonSubTypes, it enables discriminated union JSON patterns — a "type" field in the JSON determines which data class Jackson constructs. In Kotlin, @JsonTypeInfo with sealed classes provides compile-time exhaustiveness checking via when expressions, eliminating unhandled variant bugs.
@field: annotation target
A Kotlin use-site target specifier that applies an annotation to the backing field of a property rather than to the constructor parameter or getter. In Kotlin data classes, writing @NotBlank val name: String applies the annotation to the constructor parameter, which Bean Validation ignores. Writing @field:NotBlank val name: String applies the annotation to the backing JVM field, which is where Bean Validation's reflection-based constraint processing looks. The available use-site targets include @field:, @get: (getter), @set: (setter), @param: (constructor parameter), and @property: (Kotlin property). For all Jakarta Validation (javax.validation or jakarta.validation) constraints in Kotlin data classes, @field: is required to make the constraint active.
kotlin("plugin.spring")
A Kotlin compiler plugin that marks Kotlin classes annotated with Spring's @Component, @Service, @Repository, @Controller, @RestController, @Configuration, and related annotations as open, overriding Kotlin's default final modifier. Spring's CGLIB proxy mechanism — used for @Transactional, @Cacheable, and @Async — requires subclassing the target bean, which fails if the class is final. Without this plugin, Spring Boot applications with @Transactional service methods throw BeanCreationException: could not generate CGLIB subclass at startup. The plugin is applied as kotlin("plugin.spring") in the Gradle plugins block or kotlin-spring in Maven.

FAQ

How does Kotlin Spring Boot serialize data classes to JSON?

Kotlin Spring Boot uses Jackson with the jackson-module-kotlin extension to serialize data classes automatically. When a @RestController method returns a data class instance, Spring's MVC framework invokes Jackson's ObjectMapper to convert the object to a JSON string, setting Content-Type: application/json on the response. A data class like data class User(val name: String, val age: Int) produces &lbrace;'&lbrace;'&rbrace;"name":"Alice","age":30&rbrace; with zero configuration. Jackson reads Kotlin data class properties via reflection, mapping each val/var property to a JSON key using the property name by default. The jackson-module-kotlin module — added as a dependency and auto-registered by Spring Boot since version 2.3 — is required to handle Kotlin-specific class features: primary constructor parameters, nullable types (String?), and default parameter values. Without this module, Jackson attempts to use a no-arg constructor that Kotlin data classes do not generate by default, causing an InvalidDefinitionException at runtime.

Why do I need jackson-module-kotlin in my Spring Boot project?

Kotlin data classes do not generate a no-arg constructor by default, which is what standard Java Jackson requires for deserialization. jackson-module-kotlin solves this by using Kotlin reflection to inspect the primary constructor and map JSON fields to constructor parameters directly. Without it, deserializing JSON into a Kotlin data class throws a MismatchedInputException or InvalidDefinitionException. The module also handles 3 other Kotlin-specific concerns: nullable types (String? is correctly deserialized as null rather than throwing), default parameter values (omitted JSON keys fall back to Kotlin defaults), and companion object serialization. Add it to build.gradle.kts with implementation("com.fasterxml.jackson.module:jackson-module-kotlin"). Spring Boot 2.3+ auto-configures it when it is on the classpath, so no @Bean registration is needed. The spring-boot-starter-web dependency does not include it automatically — you must add it explicitly. The module version should match your jackson-databind version to avoid classpath conflicts; Spring Boot's BOM manages this alignment when you use the managed dependency.

How do I handle nullable types in Kotlin Spring Boot JSON?

Kotlin nullable types (String?, Int?) map directly to JSON null. With jackson-module-kotlin, a property declared as val bio: String? serializes to "bio":null when the value is null, and deserializes JSON null back to Kotlin null — no annotation required. Without the module, Jackson does not understand Kotlin's nullability metadata and may throw exceptions or fail to assign null correctly. For @RequestBody deserialization, if a JSON field is missing entirely (not sent by the client), the behavior depends on the Kotlin default: val bio: String? = null defaults to null if the JSON key is absent, while val name: String throws a MissingKotlinParameterException if "name" is missing. To make all fields optional during partial updates (PATCH), declare them all as nullable with defaults: data class UpdateRequest(val name: String? = null, val age: Int? = null). For controlling null serialization in output, add @JsonInclude(JsonInclude.Include.NON_NULL) to the data class to suppress null fields from the JSON response — useful for keeping API responses compact.

How do I rename JSON fields in Kotlin Spring Boot?

There are 2 main approaches for renaming JSON fields in Kotlin Spring Boot. For individual properties, use @JsonProperty("new_name") on the property: val userId: String with @JsonProperty("user_id") serializes to &lbrace;'&lbrace;'&rbrace;"user_id":"..."&rbrace;. For an entire data class, use @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) to automatically convert all camelCase properties to snake_case — userId becomes user_id, createdAt becomes created_at — without annotating each field. You can also set a global naming strategy via Spring Boot's application.properties: spring.jackson.property-naming-strategy=SNAKE_CASE applies snake_case conversion to all controllers in the application. The precedence order is: individual @JsonProperty overrides @JsonNaming, which overrides the global application.properties setting. For cases where the Kotlin property name is a reserved word or conflicts with a JSON key, @JsonProperty is the only option. Note that @JsonNaming requires jackson-module-kotlin because it relies on Kotlin reflection to read property names correctly from data class constructors.

How do I use sealed classes for JSON polymorphism in Kotlin?

Kotlin sealed classes model discriminated union JSON responses using Jackson's @JsonTypeInfo and @JsonSubTypes annotations. Add @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") to the sealed class to tell Jackson to include a "type" discriminant field in JSON. Add @JsonSubTypes listing each subclass: @JsonSubTypes(JsonSubTypes.Type(value = SuccessResponse::class, name = "success"), JsonSubTypes.Type(value = ErrorResponse::class, name = "error")). When a @RestController method returns the sealed class type, Jackson serializes the correct subclass and includes the "type" field: &lbrace;'&lbrace;'&rbrace;"type":"success","data":&lbrace;'&lbrace;'&rbrace;...&rbrace;&rbrace; or &lbrace;'&lbrace;'&rbrace;"type":"error","code":404,"message":"..."&rbrace;. For deserialization (@RequestBody), Jackson reads the "type" field and constructs the matching subclass. There are 3 alternatives to @JsonSubTypes: a custom JsonDeserializer, @JsonTypeInfo with EXISTING_PROPERTY to use a field already present in the JSON, or a manual when expression in a @JsonDeserializer. Sealed classes combined with @RestController give Kotlin APIs a type-safe way to model multi-variant API responses without nullability hacks.

How is Kotlin Spring Boot JSON different from Java Spring Boot?

The core Spring MVC and Jackson machinery is identical — @RestController, @RequestBody, @ResponseBody, and Jackson's ObjectMapper work the same way. The 4 differences are Kotlin-specific: First, Kotlin data classes replace Java POJOs — they generate equals(), hashCode(), toString(), and copy() automatically, reducing boilerplate by 60–80% compared to annotated Java classes. Second, jackson-module-kotlin is required; Java Spring Boot works with Jackson alone. Third, Kotlin nullable types (String?) replace Java @Nullable annotations and integrate with Jackson null handling without extra configuration. Fourth, Kotlin sealed classes replace Java inheritance hierarchies for polymorphic JSON — no need for abstract base classes with @JsonSubTypes in a separate file. A Java Spring Boot controller method public ResponseEntity<User> getUser(@RequestBody CreateUserRequest req) maps directly to fun getUser(@RequestBody req: CreateUserRequest): ResponseEntity<User> in Kotlin. The build configuration differs: Kotlin adds the kotlin-spring plugin (id("org.jetbrains.kotlin.plugin.spring")) to make Spring proxy classes compatible with Kotlin's default final classes.

How do I validate JSON request bodies in Kotlin Spring Boot?

Kotlin Spring Boot uses Bean Validation (Jakarta Validation, formerly javax.validation) with @Valid on the @RequestBody parameter. Add the spring-boot-starter-validation dependency, then annotate data class properties with constraints: @field:NotBlank, @field:Size(min=1, max=100), @field:Email, @field:Min(0). The @field: prefix is required in Kotlin to target the backing field rather than the constructor parameter. When @Valid is present on @RequestBody, Spring validates the deserialized object before calling the controller method. Validation failures throw a MethodArgumentNotValidException, which Spring resolves to HTTP 400 by default. To customize the error response, add an @ExceptionHandler(MethodArgumentNotValidException::class) method that reads ex.bindingResult.fieldErrors and returns a structured JSON body: &lbrace;'&lbrace;'&rbrace;"errors":&lbrace;'&lbrace;'&rbrace;"name":["must not be blank"]&rbrace;&rbrace;. The getFieldErrors() method returns all constraint violations, not just the first — set spring.mvc.throw-exception-if-no-handler-found=true in properties to ensure missing routes also return structured JSON rather than HTML. For nested data class validation, add @field:Valid to the nested object property to trigger recursive validation.

How do I return a list of objects as JSON from a Kotlin Spring Boot controller?

Return a List<T> or Collection<T> directly from a @RestController method — Jackson serializes it to a JSON array automatically. For example, fun getUsers(): List<User> returns [&lbrace;'&lbrace;'&rbrace;"name":"Alice","age":30&rbrace;,&lbrace;'&lbrace;'&rbrace;"name":"Bob","age":25&rbrace;]. Wrap in ResponseEntity<List<User>> if you need to control the HTTP status code: return ResponseEntity.ok(users). For paginated responses, Spring Data's Page<T> from a repository returns a JSON object with content, totalElements, totalPages, and number fields — return Page<User> from a method that accepts a Pageable parameter. For empty lists, Jackson serializes listOf() as [] — never null. If you need a custom wrapper envelope (e.g., &lbrace;'&lbrace;'&rbrace;"data":[...],"total":2&rbrace;), create a data class: data class ListResponse<T>(val data: List<T>, val total: Int) and return that instead. For very large lists (10,000+ items), prefer streaming with ResponseBodyEmitter or Server-Sent Events rather than materializing the full list in memory — Jackson supports streaming serialization via ObjectMapper.writeValue(OutputStream, value) which avoids building the full JSON string.

Further reading and primary sources