How should you design and manage Json instances across an application — strictness for inbound vs outbound, reuse, and copying configuration?
answer
- Reuse shared instances; never per-call
- inbound tolerant (ignoreUnknownKeys), inbound not lenient
- outbound strict/compact
- Json(from = base) { } to derive variants
- serializersModule + discriminator on the base
basics
~20 sCreate 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 sTreat 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 linesimport 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 requestgo deeper
Knows to reuse a Json instance rather than recreate it.
Configures distinct inbound (ignoreUnknownKeys) and outbound instances and reuses them.
Uses Json(from = base) to derive variants, keeps serializersModule on the base, picks asymmetric strictness.
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