Skip to content

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.

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.

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")
}
}
}

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"

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")
  • cards.arda.operations.shopaccess.email.business.EmailJob / EmailJobSerializer — uses status as the discriminator; each variant’s status field 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.
  • IdempotencyTypedIdempotencyOutcome and its PDEV-768 serialization gap that motivates case (b) above.
  • Functional Programming — Railway-Oriented Programming that sealed outcomes participate in.