ADR-0003 — jsoniter-scala for JSON

Context

SCALA_CODE_STYLE.md §"JSON Codecs" specifies jsoniter-scala. PLAN.md §3.3 specified upickle. The first version of this ADR chose upickle, on two grounds: that sttp client4 ships a first-party upickle integration, and that upickle's transitive footprint is small.

Both grounds turned out to be weaker than they looked.

The sttp integration was never used. The transport reads every response body as a String and hands it to modules/codec, precisely so that the JSON library stays out of the transport — so the integration module was a dependency the build declared and no code imported. It has been removed.

The footprint argument was a wash: jsoniter-scala's core is comparable, and its macros module is compile-time only.

Later correction. That last clause was wrong twice over. mvnDeps in Mill is compile and runtime scope, so a declared jsoniter-scala-macros reaches every consumer's classpath through the published POM; and this build never needed it in the first place, because the section below hand-writes its one codec instead of deriving any. The dependency has since been dropped — see "Consequences".

Meanwhile the style guide — which CLAUDE.md names the single source of truth for the HTTP/JSON boundary — said jsoniter all along.

Decision

Use jsoniter-scala (com.github.plokhotnyuk.jsoniter-scala), 2.39.1 at the time this was decided. Only the jsoniter-scala-core artifact; the running pin lives in build.millVersions.jsoniter and moves with routine bumps.

How it is used, and why not the obvious way

jsoniter derives a codec per target type. That is the right design for a schema you control, and the wrong one for this API. docs/HAZARDS.md §1 measures that no response definition in the pinned spec declares required, that nullable never appears, and that live payloads send JSON null for fields the spec types as arrays and objects. A derived codec answers all three by failing.

So the boundary is two steps rather than one:

  1. jsoniter parses the body into JsonValue, a small document model with a hand-written JsonValueCodec;
  2. the DTO assembles itself from that document through JsonFields, whose accessors treat absent, null and wrong-kind as one and the same.

This is the same shape the codebase already had — the ~200 DTO readers were written against JsonFields, not against the JSON library — which is why swapping the engine changed the two files underneath and left the DTOs alone.

Consequences

Good:

Bad:

Rejected alternatives

Alternative Why rejected
Keep upickle The style guide says jsoniter, the sttp integration that justified it was unused, and ujson's Double numeric model was a latent precision defect.
jsoniter with derived codecs per DTO Cannot express "every field optional, null and absent identical, unknown kinds tolerated" without a per-field knob; docs/HAZARDS.md §1 shows the API requires exactly that.
circe A larger dependency, and its optics would not change the shape of the problem above.