Polymorphic Serialization in Kotlin

AndroidKotlin
Share on LinkedIn

Our webhook endpoint accepted "type": "com.internal.LegacyRefund" in JSON because polymorphic defaults were left on. A partner rename broke mobile clients on the next release. Polymorphic serialization in kotlinx.serialization is powerful when you control the wire format; careless defaults turn JSON into a classpath leak.

kotlinx.serialization supports polymorphic hierarchies through sealed classes, explicit subclass registration, and custom serializers—without Gson-style runtime reflection.

Sealed class with discriminator

@Serializable
@JsonClassDiscriminator("payment_method")
sealed interface PaymentMethod {
    @Serializable
    @SerialName("card")
    data class Card(val last4: String, val brand: String) : PaymentMethod

    @Serializable
    @SerialName("bank")
    data class Bank(val routing: String) : PaymentMethod
}

JSON:

{
  "payment_method": "card",
  "last4": "4242",
  "brand": "visa"
}

Configure Json:

val json = Json {
    serializersModule = SerializersModule {
        polymorphic(PaymentMethod::class) {
            subclass(PaymentMethod.Card::class)
            subclass(PaymentMethod.Bank::class)
        }
    }
    classDiscriminator = "payment_method"
    ignoreUnknownKeys = true
}

Open polymorphism (non-sealed)

For interfaces not sealed—register all subclasses explicitly:

polymorphic(Event::class) {
    subclass(PageView::class)
    subclass(ButtonClick::class)
}

Missing registration fails deserialization—compile-time module safety when combined with tests.

Custom serializer for legacy APIs

When discriminator format is non-standard:

object PaymentMethodSerializer : JsonContentPolymorphicSerializer<PaymentMethod>(PaymentMethod::class) {
    override fun selectDeserializer(element: JsonElement): DeserializationStrategy<PaymentMethod> {
        val type = element.jsonObject["payment_method"]?.jsonPrimitive?.content
        return when (type) {
            "card" -> PaymentMethod.Card.serializer()
            "bank" -> PaymentMethod.Bank.serializer()
            else -> throw SerializationException("Unknown payment_method: $type")
        }
    }
}

@Serializable(with = PaymentMethodSerializer::class)
sealed interface PaymentMethod { /* ... */ }

Use for nested discriminators or union types from OpenAPI oneOf.

Lists of polymorphic items

@Serializable
data class Batch(val items: List<@Polymorphic PaymentMethod>)

Or wrap:

@Serializable
data class Envelope(val payload: PaymentMethod)

Ensure module registers all runtime subtypes.

Testing round-trip

@Test
fun roundTrip() {
    val original: PaymentMethod = PaymentMethod.Card("4242", "visa")
    val encoded = json.encodeToString(PolymorphicSerializer(PaymentMethod::class), original)
    val decoded = json.decodeFromString(PolymorphicSerializer(PaymentMethod::class), encoded)
    assertEquals(original, decoded)
}

Add golden-file tests for partner payloads.

Versioning and unknown types

@Serializable
@SerialName("unknown")
data class UnknownPayment(val raw: JsonObject) : PaymentMethod

Fallback deserializer maps unrecognized discriminators to Unknown for logging without crashing.

Document allowed SerialName values in OpenAPI—serialization labels are your public contract.

KMP and iOS

Keep @SerialName stable across platforms. Generated serializers live in commonMain—same JSON on Android and iOS without Swift Codable duplication.

Performance and wire size considerations

Polymorphic JSON carries a discriminator on every object—budget for the extra bytes on high-volume topics. Prefer sealed hierarchies with a single known supertype per endpoint rather than one mega-interface serialized polymorphically everywhere. When payloads are large and types are known at compile time, a non-polymorphic sealed wrapper avoids discriminator overhead:

@Serializable
data class PaymentEnvelope(val method: PaymentMethod) // concrete field, not @Polymorphic

For server-driven types you cannot seal, cache SerializersModule instances—building modules per request allocates unnecessarily.

Debugging deserialization failures

SerializationException messages include JSON path when using Json { isLenient = false }. Log the raw payload hash, not the payload itself, when PII is present. Common fixes:

Add contract tests in CI that decode fixture files from commonTest/resources/partner-payloads/ on every PR.

What to measure after rollout

Track error rates, tail latency, and resource utilization for two weeks after changes land—most regressions appear under real traffic mixes, not in staging smoke tests. Keep a rollback path documented: feature flags, Helm revision, or Git revert with known good digest. Review on-call pages tied to the topic quarterly; delete alerts that never fire and add thresholds that would have caught your last incident.

Run a short blameless postmortem if production surprised you, even for minor issues. The goal is updating this runbook section with one concrete lesson per quarter so the next engineer inherits context, not just configuration snippets.

Documentation your team should maintain

Maintain a one-page runbook link from your main service README: prerequisites, owner rotation, last drill date, and known sharp edges. Link to vendor docs in the Resources section below but capture org-specific decisions (CIDR ranges, cluster names, approval gates) in internal docs that stay current. New hires should deploy a safe canary within a week using only that runbook—if they cannot, the doc is incomplete.

Pre-production checklist

Before promoting to production, walk through this list with someone who was not the primary author—fresh eyes catch assumptions.

If any item is "we will do that later," treat it as a release blocker for tier-1 services.

Common questions from reviewers

Reviewers and auditors often ask whether this approach scales with team growth and whether it fails safely. Answer explicitly in your design doc: what happens when dependencies are down, when credentials expire, and when traffic doubles overnight. Prefer defaults that deny or degrade gracefully over defaults that fail open. Document known limits (throughput ceilings, supported versions, regions) in the same place operators look during incidents—avoid scattering critical constraints across Slack threads.

Resources

Frequently asked questions

How does kotlinx.serialization encode polymorphic types by default?

You register subclasses in a SerializersModule and enable polymorphic serialization. JSON output includes a type discriminator field—default key "type" with fully qualified class name unless configured. Deserialization uses the discriminator to pick the correct serializer.

Should I use class names or short type labels in JSON?

Never expose fully qualified JVM class names in public APIs—they leak implementation and break on refactor. Use JsonClassDiscriminator with @SerialName on each subclass for stable short labels like "card" and "bank_transfer".

How do I handle unknown polymorphic types from a server?

Register a default deserializer or catch SerializationException and map to Unknown sealed subtype. For forward compatibility, ignore unknown discriminators when business rules allow, or version your API explicitly.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →