Working with JSON in Kora¶
This guide introduces JSON request and response mapping in Kora. It covers how @Json selects JSON mappers for HTTP bodies, how request and response DTOs become the typed boundary of an API, and how
Kora generates serialization code through annotation processing. You will also see how JSON mapping fits into the compile-time dependency graph that powers the application.
If you want to check your progress along the way, use the finished working example: Kora Java JSON App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin JSON App.
What You'll Build¶
You will build a JSON-first HTTP API with:
- JSON request parsing for
POST /users - JSON response serialization for
GET /users - Polymorphic JSON response for
GET /users/{id}using sealed types - Type-safe DTO contracts for request and response models
What You'll Need¶
- JDK 17 or later
- Gradle 7+
- A text editor or IDE
- Completed Creating Your First Kora App
Prerequisites¶
Required: Complete Basic Kora Setup
This guide assumes you have completed Creating Your First Kora App and have a working Kora application graph with the HTTP server baseline in place.
If you haven't completed the getting started guide yet, do that first, because this guide adds JSON request and response mapping on top of that baseline.
Overview¶
JSON is usually the first real data boundary in an HTTP API. A plain string response is enough to prove the server works, but real endpoints exchange structured request and response objects. This guide shows how Kora turns those objects into JSON without making controller code manually parse or build JSON strings.
The important shift is that JSON becomes a transport representation, not the application model itself. Application code should work with typed objects, while the framework handles how those objects are encoded on the wire.
JSON Mapping in Kora¶
Kora JSON support is based on generated mappers. When you add the JSON module and annotate HTTP bodies with @Json, Kora knows that the request body should be deserialized into a Java or Kotlin type
and the response value should be serialized back to JSON. The mapper code is generated at compile time, so missing or unsupported mappings are caught early.
That means the controller can work with typed DTOs:
- request DTOs describe what the API accepts
- response DTOs describe what the API returns
- generated JSON mappers handle the transport representation
DTOs as API Contracts¶
DTOs are not just convenience classes. They are the public shape of your API. A UserRequest says which fields a client must send, while UserResponse says which fields the service returns. Keeping
that boundary explicit makes later guides easier: validation can attach rules to DTOs, HTTP routes can reuse them, and tests can assert stable response shapes.
Type-Safe Results¶
This guide also introduces a sealed result model. A sealed result is useful when one operation can produce several known outcomes, such as success or an error state. Instead of returning loose maps or throwing exceptions for every branch, the code can express those outcomes as a closed set of types.
The important idea is that JSON mapping should support your application model, not replace it. Application code works with typed request, response, and result objects; Kora handles the JSON boundary.
The practical flow is:
- add the JSON module and annotation processor support
- create request and response DTOs
- annotate controller inputs and outputs with
@Json - let Kora generate JSON mappers at compile time
- use a sealed result model to keep success and error outcomes typed
Dependencies¶
Modules¶
Update your application graph to include JSON support.
Update src/main/java/ru/tinkoff/kora/guide/json/Application.java:
package ru.tinkoff.kora.guide.json;
import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule, // <----- Connected module
LogbackModule,
UndertowHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Update src/main/kotlin/ru/tinkoff/kora/guide/json/Application.kt:
package ru.tinkoff.kora.guide.json
import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule, // <----- Connected module
LogbackModule,
UndertowHttpServerModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
DTO¶
Create src/main/java/ru/tinkoff/kora/guide/json/dto/UserRequest.java:
package ru.tinkoff.kora.guide.json.dto;
import ru.tinkoff.kora.json.common.annotation.Json;
@Json
public record UserRequest(String name, String email) {}
Create src/main/java/ru/tinkoff/kora/guide/json/dto/UserResponse.java:
Annotating the DTO classes themselves is intentional. It tells Kora to generate the JSON reader and writer for the DTO during normal annotation processing, which avoids late-phase mapper generation warnings when the same type is later used through an HTTP body, cache value, Kafka payload, or another JSON boundary.
After compilation, Kora generates JSON readers and writers for these DTOs:
The generated request reader checks JSON tokens and required fields before constructing the record:
private static String read_name(JsonParser __parser, int[] __receivedFields) throws IOException {
var __token = __parser.nextToken();
__receivedFields[0] = __receivedFields[0] | (1 << 0);
if (__token == JsonToken.VALUE_STRING) {
return __parser.getText();
} else {
throw new JsonParseException(__parser, "Expecting [VALUE_STRING] token for field 'name', got " + __token);
}
}
return new UserRequest(name, email);
private fun read_name(__parser: JsonParser, __receivedFields: IntArray): String {
val __token = __parser.nextToken()
__receivedFields[0] = __receivedFields[0] or (1 shl 0)
if (__token == JsonToken.VALUE_STRING) {
return __parser.text
}
throw JsonParseException(__parser, "Expecting [VALUE_STRING] token for field 'name', got " + __token)
}
return UserRequest(name!!, email!!)
The generated response writer writes exactly the DTO fields that form the HTTP response contract:
_gen.writeStartObject(_object);
if (_object.id() != null) {
_gen.writeFieldName(_id_optimized_field_name);
_gen.writeString(_object.id());
}
if (_object.createdAt() != null) {
_gen.writeFieldName(_createdAt_optimized_field_name);
createdAtWriter.write(_gen, _object.createdAt());
}
_gen.writeEndObject();
This is the first place where @Json becomes concrete: request DTOs get generated readers, response DTOs get generated writers, and unsupported shapes fail at compile time instead of being discovered
through runtime reflection.
Service¶
Create src/main/java/ru/tinkoff/kora/guide/json/service/UserService.java:
package ru.tinkoff.kora.guide.json.service;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.json.dto.UserRequest;
import ru.tinkoff.kora.guide.json.dto.UserResponse;
import ru.tinkoff.kora.guide.json.dto.UserResult;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Component
public final class UserService {
private final Map<String, UserResponse> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
public UserResponse createUser(UserRequest request) {
String id = String.valueOf(idGenerator.getAndIncrement());
UserResponse user = new UserResponse(id, request.name(), request.email(), LocalDateTime.now());
users.put(id, user);
return user;
}
public List<UserResponse> getAllUsers() {
return users.values().stream().toList();
}
public UserResult getUser(String id) {
UserResponse user = users.get(id);
if (user != null) {
return new UserResult.UserSuccess(UserResult.Status.OK, user);
}
return new UserResult.UserError(UserResult.Status.ERROR, "User not found with id: " + id);
}
}
Create src/main/kotlin/ru/tinkoff/kora/guide/json/service/UserService.kt:
package ru.tinkoff.kora.guide.json.service
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.json.dto.UserRequest
import ru.tinkoff.kora.guide.json.dto.UserResponse
import ru.tinkoff.kora.guide.json.dto.UserResult
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@Component
class UserService {
private val users = ConcurrentHashMap<String, UserResponse>()
private val idGenerator = AtomicLong(1)
fun createUser(request: UserRequest): UserResponse {
val id = idGenerator.getAndIncrement().toString()
val user = UserResponse(
id = id,
name = request.name,
email = request.email,
createdAt = LocalDateTime.now()
)
users[id] = user
return user
}
fun getAllUsers(): List<UserResponse> = users.values.toList()
fun getUser(id: String): UserResult {
val user = users[id]
return if (user != null) {
UserResult.UserSuccess(UserResult.Status.OK, user)
} else {
UserResult.UserError(UserResult.Status.ERROR, "User not found with id: $id")
}
}
}
Sealed Response Model¶
Create src/main/java/ru/tinkoff/kora/guide/json/dto/UserResult.java:
package ru.tinkoff.kora.guide.json.dto;
import ru.tinkoff.kora.json.common.annotation.Json;
import ru.tinkoff.kora.json.common.annotation.JsonDiscriminatorField;
import ru.tinkoff.kora.json.common.annotation.JsonDiscriminatorValue;
@Json
@JsonDiscriminatorField("status")
public sealed interface UserResult permits UserResult.UserSuccess, UserResult.UserError {
@Json
enum Status {
OK,
ERROR
}
Status status();
@Json
@JsonDiscriminatorValue("OK")
record UserSuccess(Status status, UserResponse user) implements UserResult {}
@Json
@JsonDiscriminatorValue("ERROR")
record UserError(Status status, String message) implements UserResult {}
}
Create src/main/kotlin/ru/tinkoff/kora/guide/json/dto/UserResult.kt:
package ru.tinkoff.kora.guide.json.dto
import ru.tinkoff.kora.json.common.annotation.Json
import ru.tinkoff.kora.json.common.annotation.JsonDiscriminatorField
import ru.tinkoff.kora.json.common.annotation.JsonDiscriminatorValue
@Json
@JsonDiscriminatorField("status")
sealed interface UserResult {
@Json
enum class Status {
OK,
ERROR
}
val status: Status
@Json
@JsonDiscriminatorValue("OK")
data class UserSuccess(
override val status: Status,
val user: UserResponse
) : UserResult
@Json
@JsonDiscriminatorValue("ERROR")
data class UserError(
override val status: Status,
val message: String
) : UserResult
}
After compilation, the generated sealed reader and writer show how Kora uses the discriminator field:
The writer chooses the concrete subtype by Java type:
The reader performs the opposite operation by reading the status discriminator:
var discriminator = DiscriminatorHelper.readStringDiscriminator(bufferingParser, "status");
if (discriminator == null) {
throw new JsonParseException(__parser, "Discriminator required, but not provided");
}
return switch(discriminator) {
case "OK" -> userSuccessReader.read(bufferedParser);
case "ERROR" -> userErrorReader.read(bufferedParser);
default -> throw new JsonParseException(__parser, "Unknown discriminator: '" + discriminator + "'");
};
val discriminator = DiscriminatorHelper.readStringDiscriminator(bufferingParser, "status")
if (discriminator == null) throw JsonParseException(__parser, "Discriminator required, but not provided")
return when(discriminator) {
"ERROR" -> userErrorReader.read(bufferedParser)
"OK" -> userSuccessReader.read(bufferedParser)
else -> throw JsonParseException(__parser, "Unknown discriminator")
}
This generated code explains polymorphic JSON without guessing: @JsonDiscriminatorField("status") becomes an actual discriminator lookup, and each subtype has its own generated reader and writer.
Controller¶
Create src/main/java/ru/tinkoff/kora/guide/json/controller/UserController.java:
package ru.tinkoff.kora.guide.json.controller;
import java.util.List;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.json.dto.UserRequest;
import ru.tinkoff.kora.guide.json.dto.UserResponse;
import ru.tinkoff.kora.guide.json.dto.UserResult;
import ru.tinkoff.kora.guide.json.service.UserService;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.annotation.Path;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
return userService.createUser(request);
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
public List<UserResponse> getAllUsers() {
return userService.getAllUsers();
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
@Json
public UserResult getUser(@Path String id) {
return userService.getUser(id);
}
}
Create src/main/kotlin/ru/tinkoff/kora/guide/json/controller/UserController.kt:
package ru.tinkoff.kora.guide.json.controller
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.json.dto.UserRequest
import ru.tinkoff.kora.guide.json.dto.UserResponse
import ru.tinkoff.kora.guide.json.dto.UserResult
import ru.tinkoff.kora.guide.json.service.UserService
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.annotation.Path
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class UserController(
private val userService: UserService
) {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
return userService.createUser(request)
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
fun getAllUsers(): List<UserResponse> {
return userService.getAllUsers()
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
@Json
fun getUser(@Path id: String): UserResult {
return userService.getUser(id)
}
}
Generated JSON Code¶
@Json is compile-time code generation, not runtime reflection.
After you run:
inspect the generated JSON readers and writers:
guides/guide-json-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/json/dto/$UserRequest_JsonReader.java
guides/guide-json-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/json/dto/$UserResponse_JsonWriter.java
guides/guide-json-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/json/dto/$UserResult_JsonReader.java
guides/guide-json-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/json/dto/$UserResult_JsonWriter.java
guides/kotlin/guide-kotlin-json-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/json/dto/$UserRequest_JsonReader.kt
guides/kotlin/guide-kotlin-json-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/json/dto/$UserResponse_JsonWriter.kt
guides/kotlin/guide-kotlin-json-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/json/dto/$UserResult_JsonReader.kt
guides/kotlin/guide-kotlin-json-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/json/dto/$UserResult_JsonWriter.kt
The DTO and sealed-response chapters showed the generated fragments next to the model that produced them. Generated JSON classes are also excellent context for AI assistants: they show the exact field names, discriminator values, null handling, and subtype mapping Kora compiled from your DTOs.
Run Application¶
First verify compilation and tests:
Then run the app:
Check Application¶
Create user:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
Get all users:
Get user by id (success):
Get user by id (not found):
Best Practices¶
- Keep request/response DTOs simple and immutable.
- Use sealed responses when endpoint outcomes have different payload shapes.
- Keep business logic in service layer, not in controller methods.
- Use compile-time generated JSON mapping (
@Json) instead of manual parsing. - Put
@Jsonon request/response DTO classes that are serialized or deserialized as JSON, not only on controller parameters and return values. - Inspect generated readers and writers when JSON shape or polymorphic decoding is unclear.
Summary¶
You implemented JSON request/response handling in Kora with:
- DTO-based API contracts
- automatic JSON mapping
- polymorphic sealed JSON responses with discriminator field
- generated JSON readers and writers for DTO and sealed response contracts
Key Concepts¶
json-moduleenables JSON processing in Kora HTTP apps.@Jsonhandles request deserialization and response serialization.- Sealed types with
@JsonDiscriminatorFieldand@JsonDiscriminatorValueprovide type-safe polymorphic API responses. - Generated JSON source shows the exact serialization and deserialization behavior.
Troubleshooting¶
Request body is not deserialized
- Ensure
json-moduleis added to dependencies. - Ensure controller request parameter is annotated with
@Json.
Polymorphic response does not serialize as expected
- Check
@JsonDiscriminatorFieldon sealed type. - Check every subtype has
@JsonDiscriminatorValue.
HTTP routes are not found
- Verify
@HttpControllerand@HttpRouteannotations. - Verify path patterns (
/users,/users/{id}) and HTTP methods.
What's Next?¶
- Build an HTTP Server to use these JSON DTO patterns in a full CRUD API.
- Validation after HTTP Server, because validation assumes the finished CRUD controller/service/repository flow.
- Database JDBC or Cassandra Database after HTTP Server, when you are ready to replace the in-memory repository.
- OpenAPI HTTP Server after HTTP Server, to compare handwritten JSON DTOs with contract-generated transport models.
Help¶
If you encounter issues:
- compare with Kora Java JSON App and Kora Kotlin JSON App
- check the JSON documentation
- check the HTTP Server documentation
- check the HTTP Server example