skip to content

How should you design and manage Json instances across an application — strictness for inbound vs outbound, reuse, and copying configuration?

level: principalimportance: nice to knowfreq 30%

answer

  1. Reuse shared instances; never per-call
  2. inbound tolerant (ignoreUnknownKeys), inbound not lenient
  3. outbound strict/compact
  4. Json(from = base) { } to derive variants
  5. serializersModule + discriminator on the base

basics

~20 s

Create a few shared, reusable Json instances configured for their job: strict for output, tolerant (ignoreUnknownKeys) for input. Don't build Json { } per call, and derive variants with Json(from = base) { ... }.

solid answer

~40 s

Treat Json instances as configured, immutable, reusable singletons — building one compiles a JsonConfiguration and is comparatively expensive, so per-call construction is a real hotspot anti-pattern. Define purpose-specific instances: an inbound parser with ignoreUnknownKeys = true (tolerate forward-compatible API growth) but isLenient = false (still reject corrupt input); an outbound serializer that is strict and compact, optionally encodeDefaults = true for self-describing payloads; and a debug/log instance with prettyPrint = true. Derive related configs from a base with Json(from = baseJson) { override = ... } so shared settings (serializersModule, classDiscriminator) stay consistent. Keep the serializersModule (contextual/polymorphic registrations) on the base. In Spring/DI, expose these as beans. Pin wire-format choices (classDiscriminator, @SerialName, explicitNulls) as part of the API contract and change them deliberately with versioning.

code

kotlin · 7 lines
kotlin
import kotlinx.serialization.json.Json

val base = Json { classDiscriminator = "#kind" }
val inbound  = Json(from = base) { ignoreUnknownKeys = true; isLenient = false }
val outbound = Json(from = base) { prettyPrint = false }
val debug    = Json(from = base) { prettyPrint = true }
// reuse these singletons everywhere; do not build Json { } per request

go deeper

for a junior

Knows to reuse a Json instance rather than recreate it.

for a middle

Configures distinct inbound (ignoreUnknownKeys) and outbound instances and reuses them.

for a senior

Uses Json(from = base) to derive variants, keeps serializersModule on the base, picks asymmetric strictness.

for a principal

Designs the whole instance strategy as policy + wire contract, integrates with DI, and versions format changes deliberately.

## Instances are configuration, not throwaways Each `Json { }` you build compiles its options into an internal `JsonConfiguration` and resolves its `serializersModule`. That work is **not free**, so a `Json { ... }` inside a hot loop or per request is a classic performance bug. Instead, build a small number of **immutable, shared** instances and reuse them. ## Asymmetric strictness: inbound vs outbound Inbound and outbound JSON have different risk profiles: - **Inbound (decode):** be tolerant of *forward-compatible* growth but reject corruption. - `ignoreUnknownKeys = true` — survive new fields the producer adds. - `isLenient = false` — still fail fast on malformed payloads. - **Outbound (encode):** be deterministic and minimal. - strict, compact (`prettyPrint = false`). - decide `encodeDefaults`/`explicitNulls` based on whether consumers distinguish absent vs null. - **Debug/logging:** a separate `prettyPrint = true` instance. ```kotlin val base = Json { serializersModule = appModule // shared polymorphic/contextual registrations classDiscriminator = "#kind" } val inbound = Json(from = base) { ignoreUnknownKeys = true } val outbound = Json(from = base) { encodeDefaults = true; explicitNulls = false } val debug = Json(from = base) { prettyPrint = true } ``` ## Copying configuration `Json(from = baseInstance) { /* overrides */ }` creates a new instance that **inherits** the base's settings and `serializersModule`, then applies overrides. This keeps shared, contract-level choices (discriminator key, registered serializers) consistent across all derived instances and avoids drift. ## The serializersModule belongs on the base Polymorphic registrations and contextual serializers live in a `SerializersModule`. Put it on the base so every derived instance can decode the same types; otherwise an inbound instance might fail to resolve a subtype the outbound one knows. ## DI / lifecycle In a framework (e.g. Spring), expose these as singleton beans. This guarantees one instance per role, makes the configuration testable, and prevents accidental per-call construction. ## Wire-contract discipline `classDiscriminator`, `@SerialName` values, and `explicitNulls`/`encodeDefaults` behavior are observable on the wire. Treat them as part of the **public contract**: changing them can break consumers, so version such changes deliberately rather than tweaking flags casually. ## Summary checklist - Few shared instances, never per-call. - Tolerant inbound (`ignoreUnknownKeys`), strict-ish outbound. - `Json(from = base)` to derive variants without drift. - `serializersModule` on the base. - Treat format flags as contract.

  • Why not just use one global Json with all the lenient flags on?
    It would mask corrupt inbound data and bloat/loosen outbound output. Asymmetric, role-specific instances give the right strictness on each side.
  • How do you keep polymorphic registrations consistent across instances?
    Define the serializersModule on a base instance and derive others with Json(from = base) {}; they inherit the module.

Like having a few well-set printer profiles (draft vs high-quality vs forms) instead of reconfiguring the printer for every page.

saying these in an interview costs you the question

  • Building a new Json { } inside request handlers or loops
  • Using a single maximally-lenient instance for everything
  • Forgetting that serializersModule must be shared across decode/encode instances
  • Changing classDiscriminator/SerialName without treating it as a contract change

context