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.
Generated codecs are ordinary application graph components, so the rest of Kora consumes them directly:
HTTP server and HTTP client body mappers, string parameter readers and writers, and Kafka serializers and deserializers all accept a JsonReader<T> or JsonWriter<T> and are registered under the @Json tag.
The same generated codec is therefore reused across the whole application.
For a step-by-step walkthrough before the reference details, see JSON.
Dependency¶
Dependency in build.gradle:
annotationProcessor "io.koraframework:annotation-processors" //(1)!
implementation "io.koraframework:json-common"
- The annotation processor generates
JsonReaderandJsonWriterat compile time. Without it no codec is created and the graph fails with a missingJsonReader/JsonWriterdependency.
Module:
Dependency in build.gradle.kts:
ksp("io.koraframework:symbol-processors:2.0.0.RC1") //(1)!
implementation("io.koraframework:json-common")
- The
KSPprocessor generatesJsonReaderandJsonWriterat compile time. Without it no codec is created and the graph fails with a missingJsonReader/JsonWriterdependency.
Module:
json-common brings in the Jackson streaming core, so hand-written codecs work against tools.jackson.core.JsonParser and tools.jackson.core.JsonGenerator.
Generated codecs never use Jackson databind and never use Reflection.
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 @Nullable Dto read(String json) { //(1)!
return this.reader.read(json);
}
public byte[] write(Dto dto) { //(2)!
return this.writer.toByteArray(dto);
}
}
JsonReaderis declared as returning a nullable value, because a top-levelnulldocument reads asnull.- No
throwsclause and notry/catchare required:JSONfailures are unchecked.
@Component
class MyService(
private val reader: JsonReader<Dto>,
private val writer: JsonWriter<Dto>
) {
fun read(json: String): Dto = requireNotNull(reader.read(json)) //(1)!
fun write(dto: Dto): ByteArray = writer.toByteArray(dto) //(2)!
}
JsonReaderis declared as returning a nullable value, because a top-levelnulldocument reads asnull. A non-null target therefore needs an explicit check.- No
try/catchis required:JSONfailures are unchecked.
JsonReader<T> reads a value from a JsonParser, a byte[] (optionally with an offset and a length), a String, or an InputStream.
Every one of these methods is declared as returning a nullable value.
JsonWriter<T> writes a value through a JsonGenerator and can also produce the whole document at once:
toByteArray(value)-UTF-8bytes.toString(value)- a compact string.toPrettyString(value)- a formatted string.
Runtime behavior worth noting when calling the codecs directly:
read(...)returnsnullwhen the parser is positioned on aJSONnulltoken, so a top-levelnulldocument deserializes tonull.- All failures are
tools.jackson.core.JacksonExceptionsubtypes, and that class extendsRuntimeException, so no method declares a checked exception. See Errors. - The generated codec class lives in the package of the annotated type and is named
$Dto_JsonReader/$Dto_JsonWriter, with outer class names folded into the prefix for nested types ($Outer_Inner_JsonReader). Inject theJsonReader<Dto>interface rather than referring to that class.
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).
A required field that is absent from the document and a required field that is present with an explicit null are two different errors, and both are reported with the field name and the JSON path.
Optional fields¶
If a JSON field is optional and can be absent, use the @Nullable annotation:
- Kora uses JSpecify
org.jspecify.annotations.Nullable. Any annotation whose simple name isNullableis also recognized.
JSpecify @Nullable is a type-use annotation, so its position matters for nested and array types:
@Json
public record Dto(java.util.@Nullable List<String> labels, //(1)!
byte @Nullable [] payload) { } //(2)!
- The annotation applies to
List, not toString. - The annotation applies to the array, not to
byte.
For Kotlin, use Kotlin Nullability syntax and mark the parameter as nullable:
Kotlin expresses nullability in the type itself, so no annotation is needed:
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:
@JsonField only renames the key.
To read or write a single field with a specific codec, use @Mapping - see Custom Field Mappers.
Naming Strategy¶
To rename every field of a class at once, annotate the class with @NamingStrategy and pass a NameConverter implementation.
The strategy applies to both the generated JsonReader and the generated JsonWriter:
Available converters in io.koraframework.common.naming:
NoopNameConverter-myFieldNAME→myFieldNAME.CamelCaseNameConverter-myFieldNAME→myFieldName.PascalCaseNameConverter-myFieldNAME→MyFieldName.SnakeCaseNameConverter-myFieldNAME→my_field_name.SnakeCaseUpperNameConverter-myFieldNAME→MY_FIELD_NAME.
A field annotated with @JsonField keeps the name from the annotation.
A bare @JsonField with no value opts the field out of the naming strategy and keeps the declared field name.
Field Ignore¶
If a field in a DTO should not appear in the written JSON, use @JsonSkip.
The generated JsonWriter omits such a field entirely.
A type that is only ever written can be annotated with @JsonWriter instead of @Json; then no reader is generated and the skipped field imposes no constraint at all.
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, and a field-level annotation wins over the class-level one.
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.
IncludeType.NON_EMPTY is resolved at compile time, so it only takes effect when the field type is statically known to be a Collection or a Map.
On a type variable the emptiness check cannot be generated and the field behaves as IncludeType.NON_NULL.
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, data class, enum, and sealed types.
Reading requires a concrete, non-abstract type and exactly one constructor to be selectable, in this order:
- The single public constructor, if there is only one.
- The single public constructor annotated with
@JsonReader. - The single public constructor annotated with
@Json. - The single public constructor with parameters, if there is exactly one such constructor.
If none of these rules produces a single candidate, compilation fails with a message that names the type and the reason.
Java Bean and plain classes¶
@Json, @JsonReader, and @JsonWriter are not limited to record and data class.
A plain class works too: reading follows the constructor rules above, and writing uses the field accessors.
For every non-static field the writer needs a zero-argument method named field() or getField() whose return type is the field type; otherwise compilation fails and suggests adding an accessor or excluding the field with @JsonSkip.
@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; }
}
Value Types¶
A wrapper type that should appear in JSON as a bare scalar rather than as an object is described by putting @JsonReader on a static factory method and @JsonWriter on the accessor that yields the underlying value.
Kora then generates codecs that delegate to the codecs of that underlying type:
public record UserId(long id) {
@JsonReader //(1)!
public static UserId of(long value) {
return new UserId(value);
}
@JsonWriter //(2)!
public long id() {
return id;
}
}
- A
staticfactory method with exactly one parameter. The parameter type decides whichJsonReaderis delegated to. - An instance accessor without parameters. A
staticmethod taking the type itself (public static long toJson(UserId u)) works as well.
class UserId(val id: Long) {
@JsonWriter //(1)!
fun toJson(): Long = id
companion object {
@JsonReader //(2)!
fun of(value: Long): UserId = UserId(value)
}
}
- An instance function without parameters. A
companion objectfunction taking the type itself (fun toJson(u: UserId): Long) works as well. - A
companion objectfunction with exactly one parameter. The parameter type decides whichJsonReaderis delegated to.
A UserId field is then written as 42 instead of {"id":42}, and null is written as null.
This is the equivalent of Jackson @JsonValue and @JsonCreator(mode = DELEGATING).
Constraints enforced at compile time:
- At most one
@JsonReaderfactory method and at most one@JsonWritermethod per type. - The factory method must be
public static, take exactly one parameter, and return the type itself. - A type cannot have both a
@JsonReaderfactory method and a@JsonReader/@Jsonconstructor. - The
@JsonWritermethod must bepublicand return a value; astaticone takes the type as its only parameter, an instance one takes none. - The delegated type must itself have a
JsonReaderorJsonWriterin the graph, which is true for every supported type.
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; the argument must not benull.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 a NullPointerException 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 rules always win, so an undefined() field is omitted and a nullValue() field is written as null under both.
IncludeType.NON_EMPTY is the only level that adds anything, and only when the wrapped type is statically a Collection or a Map: then a nullValue() field and a field holding an empty collection or map are omitted as well.
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 {
@Json
@JsonDiscriminatorValue("created")
record Created(String id) implements Event {}
@Json
@JsonDiscriminatorValue({"deleted", "removed"}) //(1)!
record Deleted(String id, boolean permanent) implements Event {}
}
- Several discriminator values may map to one subclass. On writing, the first value is used.
@Json
@JsonDiscriminatorField("type")
sealed interface Event {
@Json
@JsonDiscriminatorValue("created")
data class Created(val id: String) : Event
@Json
@JsonDiscriminatorValue("deleted", "removed") //(1)!
data class Deleted(val id: String, val permanent: Boolean) : Event
}
- Several discriminator values may map to one subclass. On writing, the first value is used.
The sealed class or interface itself receives a common JsonReader and JsonWriter, and that is what an application injects to read or write any subtype.
Each concrete subclass must have its own codec as well, because the generated codec of the sealed type takes the subclass codecs as dependencies.
Annotating every subclass with @Json (or @JsonReader/@JsonWriter) is the simplest way to satisfy that.
Sealed abstract classes and nested sealed sub-interfaces are supported too - the hierarchy is flattened down to its concrete subclasses.
Rules that apply to the discriminator itself:
@JsonDiscriminatorValueis optional. Without it the discriminator value of a subclass is its simple class name.- The discriminator field may appear anywhere inside the object; the reader buffers the tokens it has to look past.
- On writing, the discriminator is emitted automatically unless the subclass already declares a field with that
JSONname - in that case the field itself provides the value. @JsonDiscriminatorFieldalso acceptsdefaultValue, which is the discriminator used when the field is missing from the document. Without it a missing discriminator is an error.
The JSON object below is read into the Created 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:
The mapping from JSON values to constants is built once when the codec is created, so reading an enum is a single map lookup.
A JSON value that does not match any constant is rejected with an error listing 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.
RawJson is a write-only type: the module provides a JsonWriter<RawJson> but no reader, so a DTO containing it is annotated with @JsonWriter rather than @Json.
RawJson accepts either a String or a byte[] and keeps the value as UTF-8 bytes.
Because the content is already encoded, it is written unquoted; a value that needs JSON string quoting must be passed as a regular String field instead.
Custom Field Mappers¶
To use specific mappers only for one field, annotate the field with @Mapping.
The annotation can be repeated to specify both a JsonReader and a JsonWriter:
Here, HexReader implements JsonReader<Integer> (JsonReader<Int> in Kotlin), and HexWriter implements the corresponding JsonWriter:
public final class HexWriter implements JsonWriter<Integer> { //(1)!
@Override
public void write(JsonGenerator generator, Integer value) {
generator.writeString(Integer.toHexString(value));
}
}
public final class HexReader implements JsonReader<Integer> {
@Override
public Integer read(JsonParser parser) {
if (parser.currentToken() != JsonToken.VALUE_STRING) {
throw new StreamReadException(parser, "Expected hexadecimal string");
}
return Integer.parseInt(parser.getValueAsString(), 16);
}
}
- A
finalmapper without constructor dependencies is instantiated by the generated code itself and must not be a@Component. A mapper that has constructor dependencies must be a graph component - see Components.
class HexWriter : JsonWriter<Int> { //(1)!
override fun write(generator: JsonGenerator, value: Int?) {
generator.writeString(value!!.toString(16))
}
}
class HexReader : JsonReader<Int> {
override fun read(parser: JsonParser): Int {
if (parser.currentToken() != JsonToken.VALUE_STRING) {
throw StreamReadException(parser, "Expected hexadecimal string")
}
return parser.valueAsString.toInt(16)
}
}
- A mapper without constructor dependencies (in
Kotlinclasses arefinalby default) is instantiated by the generated code itself and must not be a@Component. A mapper that has constructor dependencies must be a graph component - see Components.
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
Details worth knowing about that list:
byte[]is written and read as aBase64string.RawJsonhas a writer only, andSortedSet<T>has a reader only.Enumrequires@Jsonon the enum type itself, see Enums.Objectreads aJSONobject into aLinkedHashMap, an array into anArrayList, an integer number into aBigInteger, and a fractional number into aDouble.- Date and time types use the corresponding
ISOformats;MonthandDayOfWeekare written by name and read from either a name or a number. Mapkeys must be strings.
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 StreamReadException 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 StreamReadException(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 StreamReadException(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 @Mapping (see Custom Field Mappers).
Errors¶
Everything a codec throws at runtime is a tools.jackson.core.JacksonException, which extends RuntimeException.
No JsonReader or JsonWriter method declares a checked exception, so calling code needs neither throws nor try/catch unless it wants to handle the failure.
Reading failures are reported as tools.jackson.core.exc.StreamReadException.
Generated readers build messages that name the type, the member, and the JSON path of the failing value:
Failed to read json Dto: missing required field(s): field_1 (at <root>)
Failed to read json Dto.field4: required field must not be null (at <root>)
Failed to read json Dto.field2: expected an integer number, but got a string "abc" (at /field2)
Sealed hierarchies and enums add their own messages, both listing what was accepted:
Failed to read json Event: missing required discriminator field "type", expected one of [created, deleted, removed] (at <root>)
Failed to read json Event: unknown discriminator value "updated" for field "type", expected one of [created, deleted, removed] (at <root>)
Failed to read json enum: expected one of [1, 2], but got "3" (at /status)
When the codec is used through another Kora module the exception is translated at the boundary: a declarative HTTP server controller turns a body or parameter that cannot be parsed into a 400 response, and a Kafka producer wraps a serialization failure into a SerializationException.
Compile-time problems are reported by the Kora processor with the same structure - the type, the problem, a hint, and a fix.
The most common ones are a missing accessor for a written field, an abstract or interface type that is not a supported sealed hierarchy, and an ambiguous constructor for a reader.
Jackson¶
If Jackson databind must be used for reading and writing JSON instead of the compile-time generated codecs, use JacksonModule.
It provides @Json-tagged mappers for the HTTP client and the HTTP server, and because those are ordinary components while the codec-based ones are default components, the Jackson mappers take precedence - see Standard factory.
JacksonModule covers exactly these mappers:
HttpServerRequestMapper<T>andHttpServerResponseMapper<T>.HttpClientRequestMapper<T>,HttpClientResponseMapper<T>, andHttpClientResponseMapper<HttpResponseEntity<T>>.
Everything else - string parameters, Kafka, and any direct JsonReader/JsonWriter injection - keeps using the generated codecs.
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 "io.koraframework:annotation-processors"
implementation "io.koraframework: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:
ksp("io.koraframework:symbol-processors:2.0.0.RC1")
implementation("io.koraframework:jackson-module")
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 ObjectMapper here is tools.jackson.databind.ObjectMapper; jackson-module brings Jackson databind in transitively.
The Kora 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).