skip to content

You ship a sealed @Serializable hierarchy as a public JSON API. A teammate renames a subclass or refactors its package. Why might that break existing clients, and how do you prevent it?

level: middleimportance: should knowfreq 45%

answer

  1. no @SerialName => discriminator = FQN
  2. rename/move package => silent wire break
  3. @SerialName makes the value a stable contract
  4. ignoreUnknownKeys is for fields, not discriminator values
  5. defaultDeserializer handles unknown subtypes

basics

~20 s

By default the discriminator value is the class's full name. Renaming the class or moving its package changes that value, so old JSON no longer matches. Pin a stable value with @SerialName on every subclass.

solid answer

~40 s

Without @SerialName, a subclass's serial name defaults to its fully-qualified class name, and that string is written as the discriminator value (default key "type"). If a teammate renames the class or changes its package, the serial name silently changes, so previously stored or in-flight JSON now carries a discriminator the new code doesn't recognize — decoding throws SerializationException. The fix is to put an explicit, stable @SerialName("...") on every subclass so the wire value is decoupled from the Kotlin identifier. Treat those names as part of your public contract. For decode resilience against genuinely unknown future types, add a defaultDeserializer fallback in a polymorphic block (or for open hierarchies). Tests asserting exact JSON catch accidental drift.

go deeper

for a junior

Recognizes that the class name appears in JSON and @SerialName can rename it.

for a middle

Explains that FQN-as-discriminator makes refactors breaking and pins @SerialName as the contract.

for a senior

Adds decode resilience (defaultDeserializer) and contract tests; distinguishes unknown fields vs unknown subtypes.

for a principal

Establishes wire-contract governance: versioned serial-name registry, compatibility tests, and rollout policy across producers/consumers.

## Where the discriminator value comes from The polymorphic discriminator **value** is the subclass's **serial name**. If you don't annotate it, the serial name **defaults to the fully-qualified class name** (e.g. `com.acme.api.Event.Click`). That string is embedded in every serialized payload. ## The failure Because the FQN leaks into the wire format, ordinary refactors become **breaking API changes**: - Rename `Click` -> `Tap`: discriminator changes from `...Event.Click` to `...Event.Tap`. - Move package `api` -> `events`: prefix changes. - Convert nested -> top-level class: name changes. Old persisted documents and clients still send the old value; new code has no subtype registered under it, so decode throws `SerializationException`. ## The fix: pin @SerialName ```kotlin @Serializable sealed class Event { @Serializable @SerialName("click") // stable wire value data class Click(val x: Int, val y: Int) : Event() @Serializable @SerialName("key_press") data class Key(val code: Int) : Event() } ``` Now Kotlin names can change freely; the `@SerialName` strings are the **contract**. Choose short, stable, lowercase tokens, not class names. ## Defensive decoding for the future When new subtypes may appear that **old** clients can't know, register a fallback so decoding doesn't crash on unknown discriminators: ```kotlin val module = SerializersModule { polymorphic(Event::class) { defaultDeserializer { UnknownEvent.serializer() } } } ``` (`Json { ignoreUnknownKeys = true }` handles unknown *fields*, but NOT unknown discriminator *values* — that's what `defaultDeserializer` is for.) ## Guardrails - Unit tests that assert the **exact** JSON string for each subtype catch accidental serial-name drift in review. - Document the @SerialName tokens as the versioned contract. ## Key APIs/keywords `@SerialName`, `classDiscriminator`, `SerializationException`, `defaultDeserializer`, `ignoreUnknownKeys`, `PolymorphicSerializer`.

  • Does Json { ignoreUnknownKeys = true } help when an unknown subtype's discriminator arrives?
    No. It only ignores unknown object fields. An unknown discriminator value still throws unless you set a defaultDeserializer fallback.
  • How do you catch serial-name drift before release?
    Write tests that assert the exact encoded JSON for each subtype; a rename then fails the test in review.

Defaulting the discriminator to the class name is like printing your home address on every product — move house and all the returns get lost.

saying these in an interview costs you the question

  • Believing class renames are safe for serialized data without @SerialName
  • Confusing ignoreUnknownKeys (fields) with discriminator handling (subtypes)
  • Treating the FQN discriminator as 'just an implementation detail'
  • No tests pinning the on-wire JSON shape

context