Sealed Entity Serializer
The standard kotlinx.serialization polymorphism support works well for sealed hierarchies whose subtypes are all directly @Serializable and whose discriminator semantics are controlled entirely at the library level. A custom KSerializer is the right tool when one or more of the following conditions applies.
Decision rubric
Section titled “Decision rubric”Reach for @Serializable(with = FooSerializer::class) when:
(a) The wire discriminator must be stable across language boundaries or stored JSON.
If the discriminator value is consumed by a non-JVM client, persisted in a database column, or compared by external tooling, the kotlinx default fully-qualified class name is unsafe. A custom serializer pins the discriminator to a stable string (an enum name() or an explicit constant) that survives package refactoring.
(b) One of the sealed subtypes carries a field that is not directly @Serializable.
For example, TypedIdempotencyOutcome.Mismatch carries a Result<Req> field — Result<T> is not annotated @Serializable. Until the common-module adds direct serialization support (tracked in PDEV-768), a custom serializer handles encoding and decoding those subtypes explicitly. See also Idempotency — Serialization.
(c) The discriminator reuses an existing domain field rather than a synthetic type key.
When the sealed hierarchy’s variants are already distinguished by a stable domain field (e.g., status on EmailJob), a custom serializer projects that field as the discriminator, keeping the wire format idiomatic for callers.
If none of these apply, prefer @Serializable with a @JsonClassDiscriminator annotation (Kotlin 1.9+) or standard @SerialName on subtypes over a hand-written serializer.
Canonical implementation shape
Section titled “Canonical implementation shape”The annotation goes on the sealed interface or class — not on individual subtypes. Each subtype remains @Serializable for its own generated serializer.
@Serializable(with = EmailJobSerializer::class)sealed interface EmailJob { val status: EmailJobStatus // this field becomes the discriminator
@Serializable data class Pending(/* ... */) : EmailJob { override val status = EmailJobStatus.PENDING }
@Serializable data class Sent(/* ... */) : EmailJob { override val status = EmailJobStatus.SENT }
// ...other variants}
object EmailJobSerializer : KSerializer<EmailJob> { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("EmailJob")
override fun serialize(encoder: Encoder, value: EmailJob) { val jsonEncoder = encoder as? JsonEncoder ?: error("EmailJobSerializer requires JSON encoding") val element = when (value) { is EmailJob.Pending -> jsonEncoder.json.encodeToJsonElement( EmailJob.Pending.serializer(), value ) is EmailJob.Sent -> jsonEncoder.json.encodeToJsonElement( EmailJob.Sent.serializer(), value ) // ...other variants } // The status field is already present in the encoded subtype; // inject it explicitly only when it is not part of the subtype's own fields. jsonEncoder.encodeJsonElement(element) }
override fun deserialize(decoder: Decoder): EmailJob { val jsonDecoder = decoder as? JsonDecoder ?: error("EmailJobSerializer requires JSON decoding") val element = jsonDecoder.decodeJsonElement() as? JsonObject ?: throw SerializationException("Expected a JSON object for EmailJob") return when (val status = element["status"]?.jsonPrimitive?.content) { "PENDING" -> jsonDecoder.json.decodeFromJsonElement( EmailJob.Pending.serializer(), element ) "SENT" -> jsonDecoder.json.decodeFromJsonElement( EmailJob.Sent.serializer(), element ) // ...other variants else -> throw SerializationException("Unknown EmailJob status: $status") } }}Wire-format stability
Section titled “Wire-format stability”The discriminator value must be a stable string — either an enum name() or an explicit string constant. Never use the fully-qualified class name (the kotlinx default): it is sensitive to package refactoring and leaks internal naming to callers.
// WRONG — leaks internal name, breaks on package rename"cards.arda.operations.shopaccess.email.business.EmailJob.Sent"
// CORRECT — stable, caller-visible name"SENT"Error path
Section titled “Error path”Throw SerializationException for unrecognized discriminator values — do not return a default variant or null. Silently swallowing an unknown discriminator hides schema drift from operators.
else -> throw SerializationException("Unknown status discriminator: $status")Worked references
Section titled “Worked references”cards.arda.operations.shopaccess.email.business.EmailJob/EmailJobSerializer— usesstatusas the discriminator; each variant’sstatusfield is serialized as part of the subtype’s own data.cards.arda.common.lib.service.batch.business.Job/JobEventSerializer— the original blueprint; the shape established here was adopted by the email module.
Related pages
Section titled “Related pages”- Idempotency —
TypedIdempotencyOutcomeand itsPDEV-768serialization gap that motivates case (b) above. - Functional Programming — Railway-Oriented Programming that sealed outcomes participate in.
Copyright: © Arda Systems 2025-2026, All rights reserved