JSON

Core builds and parses JSON itself, and the mapping between a document and a Java type is code you write.

Writers and readers

JsonWriter<T> and JsonReader<T> name a mapping so that it can be reused. Json.obj() builds a tree inline, which gets repetitive once the same record is serialized in three handlers. Both interfaces have a single method, so both are lambdas:

static final JsonWriter<Deck> DECK = deck -> Json.obj()
        .put("id", deck.id())
        .put("name", deck.name());

static final JsonWriter<List<Deck>> DECKS = JsonWriter.list(DECK);

record NewDeck(String name) { }

static final JsonReader<NewDeck> NEW_DECK =
        json -> new NewDeck(json.asObject().getString("name"));
app.get("/api/decks", req -> WebResponse.json(deckService.decks(), DECKS));

app.post("/api/decks", req -> {
    Deck deck = deckService.create(req.bodyJson(NEW_DECK).name());   // no key -> 400
    return WebResponse.json(deck, DECK).status(HttpStatus.CREATED);
});

There is still no reflection. The mapping is code you wrote, so a field rename changes the wire format only if you edit it.

Reading values

getString throws Json.JsonException on a missing key or a value of the wrong type, and req.bodyJson(reader) turns that into a 400. A handler gets a whole value or none, the same contract as pathParamLong. Json.JsonException is an IllegalArgumentException, so a reader that throws the plain type for a rule of its own is rejected the same way. DateTimeException from a reader, such as a failed LocalDate.parse, also becomes a 400. Other reader exceptions remain server errors. bodyJson(reader) wraps input rejection in HttpException, so custom handling of these errors targets that type or the 400 error handler. A Json.JsonException thrown directly by handler code remains eligible for its own exception handler. asLong and getLong reject a number with a fractional part rather than truncating it: 1.5 is not a long, while 2.0 and 1e3 are whole and read as one. For a key that is allowed to be absent, optString, optLong, optDouble, and optBoolean answer a default instead, for a missing key and for an explicit JSON null alike. optObject and optArray answer null on the same terms, and throw when the key is present with a value of another type. isString(), isNumber(), and isBoolean() report what a primitive holds, beside isNull(), so a value that may be a string or a number is told apart before it is read. A parsed array reads with for-each: for (Json.JsonValue v : json.asArray()).

Keys not known in advance

A parsed object reads with for-each as well, one Map.Entry per member, in document order. keys() lists the keys in that order and size() counts the members, so a document whose keys are data, a map of tag names to counts for instance, reads into a Map:

Map<String, Long> counts = new LinkedHashMap<>();
for (var member : req.bodyJson().asObject()) {
    counts.put(member.getKey(), member.getValue().asLong());
}

The key list is a copy and the members handed to the loop are read-only, so writing through either does not reach the object.

JsonCodec<T>

JsonCodec<T> is a writer and a reader at once, for a type that travels both ways. JsonCodec.of(writer, reader) builds one, and JsonCodec.list(codec) gives the list form. The two halves are separate interfaces because most types only go out, and one interface would leave them with an unimplementable read.

Where this stops being enough

WebResponse.json(value, writer) builds the whole document in memory before sending any of it, which an answer of a hundred thousand rows cannot afford. Large JSON and NDJSON writes one value at a time instead, and reads a body the same way.

Writing the mapping by hand is core’s decision for core, not for the application. An application with a wire format it does not own can bind objects with a library, through the same seam every other page uses: External JSON Binding.