Generated JSON Codecs
silk-json-processor generates a JsonCodec for every type annotated @JsonBound, at compile time, from the type’s own declaration.
The codec is a constant beside the type, <Type>Json.CODEC, and a handler names it the way it names a hand-written writer.
A nested type’s codec name starts with the names of the types it is nested in, so Outer.Inner generates OuterInnerJson.
Two types whose codecs would share a name, such as Outer.Inner and a top-level OuterInner in one package, are a compile error.
Nothing is reflected over, nothing registers itself, and a native image needs no entry for the type.
@JsonBound
public record Deck(long id, @JsonbProperty("deck_name") String name, @Nullable String note) {
}
app.get("/api/decks/{deckId}", req -> WebResponse.json(service.deck(req.pathParamLong("deckId")), DeckJson.CODEC));
app.post("/api/decks", req -> {
Deck deck = req.bodyJson(DeckJson.CODEC); // no id or deck_name -> 400
return WebResponse.json(service.create(deck), DeckJson.CODEC).status(HttpStatus.CREATED);
});
The generated class calls the record’s accessors on the way out and its canonical constructor on the way in, with each key encoded once as a JsonKey.
It is Java source under the build directory, readable like any other, and it depends on nothing beyond silk-json.
Setting it up
The processor runs inside javac, so a build puts it on the processor path and adds the jakarta.json.bind annotations to the compile classpath:
dependencies {
annotationProcessor 'net.benelog.silkjson:silk-json-processor:1.1.0'
compileOnly 'jakarta.json.bind:jakarta.json.bind-api:3.0.4'
}
The Gradle plugin adds both with spiderSilk { json() }, and the Maven parent puts the processor on the compiler’s path for every child.
Neither jar is needed at run time: the annotations are read when the codec is generated and never again.
An application on the module path adds requires static java.compiler; and requires static org.jspecify; to its module-info.java.
The generated code carries @Generated from java.compiler and @Nullable from jspecify, and neither is needed at run time.
The processor claims no annotation, so javac’s processing lint warns that nothing claimed @JsonBound.
A claimed @JsonBound would keep a processor that supports * from running in a round where it is the only annotation.
Claiming it would not quiet the lint either: whenever a processor runs, the lint warns about every declaration annotation that no processor claims, such as the @Generated on each codec or JUnit’s @Test.
A build that compiles with -Werror turns that lint off with -Xlint:all,-processing.
The annotations
The vocabulary is Jakarta JSON Binding’s, so a type annotated for another JSON-B implementation keeps its annotations.
@JsonBound is the one annotation of silk-json’s own, and it says only that a codec is generated.
| Annotation | What the generated codec does with it |
|---|---|
|
Names the member on the wire. Without it, the name is the component’s, the field’s, or the property’s. |
|
Leaves the property out in both directions. A record component so marked is handed null by the codec, so it cannot be a primitive. |
|
Writes a null value as |
|
Writes the named properties first, in that order, and the rest in declaration order. |
|
Marks the constructor or static factory method a class is created through, with each parameter a property.
A parameter is matched to its property by its |
|
Formats and parses a |
|
Converts the property through a |
@JsonbVisibility, @JsonbNumberFormat, @JsonbTypeSerializer, @JsonbTypeDeserializer, and @JsonbTypeInfo are not supported, and each is a compile error that says so, on a property, a type, or a package.
@JsonbTypeAdapter on a type or a package is a compile error too, since the codec adapts one property at a time.
The default order is declaration order, where JSON-B’s own default is lexicographical.
What a type looks like
A record is bound through its components and its canonical constructor.
A class is bound through its public getters, setters, and public fields, and is created through its @JsonbCreator or its public no-argument constructor.
The members a class inherits are bound too, the superclass’s properties first, and a member the class overrides is read with the class’s annotations.
A getter whose name starts with is returns boolean or Boolean.
A getter and a setter of one property take the same type, and a mismatch is a compile error.
A property set through a setter or a field keeps the class’s own default when the document leaves it out.
The codec names the type and calls its constructor, so a nested class is static, and neither the type, a type it is nested in, nor the constructor the codec calls is private.
The property types the codec knows are the primitives and their boxes, String, BigDecimal, BigInteger, UUID, URI, an enum as its name, written and read through its constants' names encoded once, a java.time type as its ISO-8601 text, Optional, OptionalInt, OptionalLong, OptionalDouble, List, Set, Collection, Iterable, Map<String, V>, the JsonValue tree, and any other type annotated @JsonBound.
A collection’s elements and a map’s values are those same types, nested as deep as needed.
Any other type is a compile error naming the property, with two ways out: annotate the type @JsonBound, or adapt it with @JsonbTypeAdapter.
Required and optional
On the way in, a record component or a creator parameter is required unless its type says the value may be absent.
A type annotated @Nullable, whichever library the annotation comes from, reads a missing or null member as null.
The annotation counts on the record component, the creator parameter, the field, or the accessor.
An Optional reads one as empty.
Any other component missing from the document, or present as null, is a JsonException that req.bodyJson(codec) answers with 400, the same contract as pathParamLong.
A member the type does not have is skipped.
This is stricter than JSON-B’s own default, which leaves a missing property at its default value, and the strictness is deliberate: a handler receives a whole value or none.
Binding a type you do not own
A type that cannot carry the annotations, a domain record or a type from another library, is bound through a mixin: a type annotated @JsonBound(Other.class), whose members of the same names carry the annotations for Other’s properties.
The codec is generated beside the mixin, in its package, as `OtherJson.
For a record, a mixin’s method names the component of the same name, so isAdmin() is the component isAdmin.
For a class, a mixin’s method names the property its JavaBeans name gives.
@JsonBound(DeckSummary.class)
interface DeckSummaryWire {
@JsonbProperty("card_count") long cardCount();
}
static final JsonCodec<DeckSummary> DECK_SUMMARY = DeckSummaryJson.CODEC;
The example application binds DeckSummary this way: the domain record carries nothing, and the web tier owns its wire format.
The wire format and renames
A property’s name on the wire is its Java name unless @JsonbProperty says otherwise, so renaming a component renames the member.
A hand-written writer changes the wire format only when someone edits it, and the no-reflection chapter states why that promise matters.
A build that wants every name written down passes the processor the option -Asilkjson.names=explicit, and a property without a @JsonbProperty name is then a compile error:
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.add('-Asilkjson.names=explicit')
}