Json
The JSON module creates efficient JsonReader and JsonWriter implementations for application classes at compile time and without using Reflection at runtime.
Generation is controlled by @Json, @JsonReader, @JsonWriter, and related field-level annotations.
JsonModule also provides ready-to-use mappers for HTTP client, HTTP server, string parameters, and Kafka.
This allows the same generated JsonReader or JsonWriter to be used across different Kora modules.
For a step-by-step walkthrough before the reference details, see JSON.
Dependency¶
Dependency in build.gradle:
Module:
Dependency in build.gradle.kts:
Module:
Writer¶
Use @JsonWriter to create only a JsonWriter.
This option is useful when the type only needs to be written to JSON:
Reader¶
Use @JsonReader to create only a JsonReader.
This option is useful when the type only needs to be read from JSON:
Reader & Writer¶
Use @Json to create both JsonReader and JsonWriter.
In most cases, @Json is the preferred annotation:
Reader And Writer Interfaces¶
JsonReader<T> and JsonWriter<T> are regular application graph components.
After generation or manual registration, they can be injected by signature like any other dependency.
@Component
public final class MyService {
private final JsonReader<Dto> reader;
private final JsonWriter<Dto> writer;
public MyService(JsonReader<Dto> reader, JsonWriter<Dto> writer) {
this.reader = reader;
this.writer = writer;
}
public Dto read(String json) throws IOException {
return this.reader.read(json);
}
public byte[] write(Dto dto) throws IOException {
return this.writer.toByteArray(dto);
}
}
JsonReader reads a value from JsonParser, byte[], String, or InputStream.
The readUnchecked(...) methods do the same, but convert IOException to UncheckedIOException.
JsonWriter writes a value through JsonGenerator and can also return byte[], a string, or a formatted string through toByteArray(...), toString(...), and toPrettyString(...).
The toByteArrayUnchecked(...), toStringUnchecked(...), and toPrettyStringUnchecked(...) methods convert IOException to UncheckedIOException.
Runtime behavior worth noting when calling the codecs directly:
read(...)returnsnullwhen the parser is positioned on aJSONnulltoken, so a top-levelnulldocument deserializes tonull.- Malformed
JSONor an unexpected token surfaces as aJacksonJsonParseException, which is a subtype ofIOException. - The
readUnchecked(...)andto...Unchecked(...)variants rethrow anyIOException(includingJsonParseException) wrapped inUncheckedIOException.
Required fields¶
By default, all fields declared in an object are considered required (NotNull).
By default, all fields declared in an object without Kotlin Nullability syntax are considered required (NotNull).
Optional fields¶
If a JSON field is optional and can be absent, use the @Nullable annotation:
- Any
@Nullableannotation is suitable, for examplejavax.annotation.Nullable,jakarta.annotation.Nullable, ororg.jetbrains.annotations.Nullable.
For Kotlin, use Kotlin Nullability syntax and mark the parameter as nullable:
Field Naming¶
If a field in JSON has a different name than the field in the class, use @JsonField.
It sets the key name in JSON and also allows specifying separate JsonReader and JsonWriter implementations for a field.
If a field needs separate mappers, specify them in reader and writer:
Field Ignore¶
If a field in a DTO should not be read or written, use @JsonSkip.
Such a field is ignored when reading and writing JSON.
Serialization Levels¶
By default, fields with null values are not written. (1)
IncludeType.NON_NULL- write the field only if the value is notnull.
To change this behavior, use @JsonInclude.
The annotation can be placed not only on a field, but also on a class; in that case, the rule applies to all fields at once.
Available options:
IncludeType.ALWAYS- always write the field.IncludeType.NON_NULL- write the field if the value is notnull.IncludeType.NON_EMPTY- write the field if the value is notnulland is not an empty collection or map.
Example:
Serialization Constructor¶
If a specific constructor should be used for reading JSON, annotate it with @JsonReader.
You can also use @Json, but @JsonReader has higher priority:
JsonReader and JsonWriter can be generated for classes, record, enum, and sealed types.
For reading a class, there must be one public constructor or a constructor explicitly annotated with @JsonReader or @Json.
Java Bean and plain classes¶
@Json, @JsonReader, and @JsonWriter are not limited to record and data class.
A plain class works too: reading requires a single public constructor (or one annotated with @JsonReader/@Json), and writing uses the field accessors.
@JsonField may be placed on private fields to rename the JSON key:
@JsonWriter
public class DtoJavaBean {
@JsonField("string_field")
private String field1;
@JsonField("int_field")
private int field2;
public DtoJavaBean(String field1, int field2) {
this.field1 = field1;
this.field2 = field2;
}
public String getField1() { return field1; }
public int getField2() { return field2; }
}
JsonNullable Wrapper¶
If reading JSON must distinguish an absent field from a field with a null value, use JsonNullable.
Main states and factory methods:
JsonNullable.undefined()- the field is absent inJSON.JsonNullable.nullValue()- the field is present and containsnull.JsonNullable.of(value)- the field is present and contains a value.JsonNullable.ofNullable(value)- createsnullValue()if the value isnull, otherwiseof(value).
When writing JSON, undefined() is skipped, nullValue() is written as null, and of(value) writes the value itself.
@Nullable vs JsonNullable¶
A plain optional field (@Nullable in Java or a nullable type in Kotlin) collapses two different JSON inputs into the same value: a field that is absent and a field that is present with an explicit null both read as null.
JsonNullable keeps these apart, which is what makes it the correct type for HTTP PATCH bodies where the client sends only the fields it actually wants to change.
The three read outcomes for a JsonNullable<T> field:
JSON input |
Read result | isDefined() |
isNull() |
value() |
|---|---|---|---|---|
{} (field absent) |
JsonNullable.undefined() |
false |
false |
throws |
{"field": null} |
JsonNullable.nullValue() |
true |
true |
null |
{"field": value} |
JsonNullable.of(value) |
true |
false |
value |
Because value() throws on undefined(), always guard access with isDefined() (or check isNull()) before calling it.
PATCH partial update¶
In a PATCH request, an absent field means "leave unchanged" while an explicit null means "clear the value".
JsonNullable lets the handler tell the two apart and apply only the fields the client actually sent:
@Json
public record UserPatch(JsonNullable<String> name,
JsonNullable<String> email) { }
public void apply(User user, UserPatch patch) {
if (patch.name().isDefined()) { //(1)!
user.setName(patch.name().value());
}
if (patch.email().isDefined()) {
user.setEmail(patch.email().value()); //(2)!
}
// fields left as undefined() are not touched
}
- The field was present in the request body, so it must be applied (even if the value is an explicit
null). value()returnsnullwhen the client sent{"email": null}, which clears the field.
@Json
data class UserPatch(
val name: JsonNullable<String>,
val email: JsonNullable<String>
)
fun apply(user: User, patch: UserPatch) {
if (patch.name.isDefined()) { //(1)!
user.name = patch.name.value()
}
if (patch.email.isDefined()) {
user.email = patch.email.value() //(2)!
}
// fields left as undefined() are not touched
}
- The field was present in the request body, so it must be applied (even if the value is an explicit
null). value()returnsnullwhen the client sent{"email": null}, which clears the field.
Interaction with serialization levels: IncludeType.ALWAYS and IncludeType.NON_NULL do not change how JsonNullable is written (its own undefined/nullValue/of rules apply).
Only IncludeType.NON_EMPTY affects JsonNullable, treating an undefined() or nullValue() field as empty so it is omitted from the output.
Sealed Classes And Interfaces¶
If different JSON objects should be read and written depending on a specific field value, use a
sealed class or interface to represent those objects.
Two annotations support sealed types:
@JsonDiscriminatorField- specifies the discriminator field in theDTOmarked as asealedclass or interface.@JsonDiscriminatorValue- specifies one or more discriminator values for a subclass.
@Json
@JsonDiscriminatorField("type")
public sealed interface Event {
@JsonDiscriminatorValue("firstType")
record FirstTypeEvent(String id, String type) implements Event {}
@JsonDiscriminatorValue("secondType")
record SecondTypeEvent(String id, Integer code) implements Event {}
@JsonDiscriminatorValue("thirdType")
record ThirdTypeEvent(String id, Boolean status) implements Event {}
}
@Json
@JsonDiscriminatorField("type")
sealed interface Event {
@JsonDiscriminatorValue("firstType")
data class FirstTypeEvent(val id: String, val type: String) : Event
@JsonDiscriminatorValue("secondType")
data class SecondTypeEvent(val id: String, val code: Integer) : Event
@JsonDiscriminatorValue("thirdType")
data class ThirdTypeEvent(val id: String, val status: Boolean) : Event
}
Subclasses receive JsonReader and JsonWriter by the same rules as if they were annotated with @Json.
The sealed class or interface itself also receives a common JsonReader and JsonWriter.
Nested sealed hierarchies are supported, and @JsonDiscriminatorValue can accept multiple values for one subclass.
The JSON object below is written to the FirstTypeEvent class:
Generic DTO types are supported, including generic sealed hierarchies.
The codec for each concrete type argument is resolved from the graph like any other field type:
Enums¶
For enum, JsonReader and JsonWriter can be generated with the same @Json, @JsonReader, and @JsonWriter annotations.
By default, the enum value in JSON is the result of toString(), so it can be overridden:
If a value other than the string from toString() is needed, annotate a public parameterless method with @Json.
In that case, a corresponding JsonReader and JsonWriter must be available for the return type:
When reading, a JSON value that does not match any enum constant throws a Jackson JsonParseException that lists the accepted values.
RawJson¶
RawJson is used when an object needs to include an already prepared JSON fragment without serializing it again.
When written, RawJson is passed to the output JSON as is, so the value must be a valid JSON fragment.
Supported Types¶
The module provides built-in types that cover most common tasks.
For collections and maps, Kora uses the JsonReader or JsonWriter of the element type.
List of supported types
- Boolean
- boolean
- Short
- short
- Integer
- int
- Long
- long
- Double
- double
- Float
- float
- byte[]
- String
- UUID
- BigInteger
- BigDecimal
- RawJson
- Object
- Enum
- List
- Set
- SortedSet
- Map
- LocalDate
- LocalTime
- LocalDateTime
- Instant
- OffsetTime
- OffsetDateTime
- ZonedDateTime
- Year
- YearMonth
- MonthDay
- Month
- DayOfWeek
- ZoneId
- Duration
Custom Types¶
If a custom type must be read or written, register a custom factory for JsonReader or JsonWriter.
Example of registering a custom JsonWriter:
Example of registering a custom JsonReader.
The reader switches on the current parser token, returns null on a JSON null, reads the expected token, and throws a JsonParseException for anything else:
@KoraApp
public interface Application {
default JsonReader<ZoneOffset> zoneOffsetJsonReader() {
return parser -> switch (parser.currentToken()) {
case VALUE_NULL -> null;
case VALUE_STRING -> ZoneOffset.of(parser.getValueAsString());
default -> throw new JsonParseException(parser,
"Expecting VALUE_STRING token, got " + parser.currentToken());
};
}
}
@KoraApp
interface Application {
fun zoneOffsetJsonReader(): JsonReader<ZoneOffset> = JsonReader { parser ->
when (parser.currentToken()) {
JsonToken.VALUE_NULL -> null
JsonToken.VALUE_STRING -> ZoneOffset.of(parser.valueAsString)
else -> throw JsonParseException(parser,
"Expecting VALUE_STRING token, got ${parser.currentToken()}")
}
}
}
A custom JsonReader<T> or JsonWriter<T> is an ordinary graph component.
Once registered, generated codecs pick it up automatically wherever a field of type T occurs, and it can also be pinned to a single field through @JsonField(reader = ..., writer = ...) (see Field Naming).
Jackson¶
If Jackson must be used for reading and writing JSON instead of the compile-time generated codecs, use JacksonModule.
It replaces the HTTP client and HTTP server request/response mappers with Jackson-backed ones.
Every JacksonModule mapper depends on an ObjectMapper component, so a factory that supplies ObjectMapper must be present in the graph. Without it the graph fails to build.
Dependency in build.gradle:
annotationProcessor "ru.tinkoff.kora:json-annotation-processor"
implementation "ru.tinkoff.kora:jackson-module"
Module and ObjectMapper factory:
@KoraApp
public interface Application extends JacksonModule {
default ObjectMapper objectMapper() { //(1)!
return new ObjectMapper();
}
}
- Required by all
JacksonModulemappers; configure it as needed (modules, features, and so on).
Dependency in build.gradle.kts:
Module and ObjectMapper factory:
@KoraApp
interface Application : JacksonModule {
fun objectMapper(): ObjectMapper = ObjectMapper() //(1)!
}
- Required by all
JacksonModulemappers; configure it as needed (modules, features, and so on).
The json-annotation-processor shown above lets @Json, @JsonReader, and @JsonWriter continue to generate codecs, so generated and Jackson serialization can coexist (for example, Jackson for HTTP and generated codecs for Kafka).
The JacksonModule HTTP mappers themselves depend only on the ObjectMapper.