Polymorphic REST DTO
A REST endpoint whose response payload depends on a sealed Kotlin type (or any sum type with named variants) faces a serialization problem: the wire representation must carry enough information for a client to reconstruct the correct variant without prior out-of-band knowledge. This page documents the kind-discriminator pattern Arda uses to solve this, with the GeneralizedMoneyDto introduced in operations v2.x as the canonical worked example.
The Problem
Section titled “The Problem”Consider a Kotlin sealed interface with two or more structurally distinct variants:
@Serializablesealed interface GeneralizedMoney { data class Value(val value: BigDecimal, val currency: Currency) : GeneralizedMoney data class Multi(val values: List<Value>) : GeneralizedMoney object Zero : GeneralizedMoney}Default kotlinx.serialization polymorphic output for sealed types embeds a type discriminator as "type" — a field name that is an implementation detail, not a domain contract. The format varies between serializer versions, and the type descriptor leaks Kotlin package names onto the wire. Frontend consumers end up parsing a fragile internal detail, not a stable contract.
The fix is to declare an explicit discriminator key and value tokens as part of the API contract before any implementation begins.
The Pattern
Section titled “The Pattern”Backend: @JsonClassDiscriminator + @SerialName
Section titled “Backend: @JsonClassDiscriminator + @SerialName”Annotate the sealed interface with @JsonClassDiscriminator("kind") to control the field name. Annotate each variant with @SerialName("token") to control the field value. Both annotations are part of the public API contract and must be treated as wire-breaking if changed.
@Serializable@JsonClassDiscriminator("kind")sealed interface GeneralizedMoneyDto { @Serializable @SerialName("money") data class Money( val value: Double, val currency: Currency ) : GeneralizedMoneyDto
@Serializable @SerialName("multi") data class Multi( val values: List<Money> ) : GeneralizedMoneyDto
@Serializable @SerialName("zero") data object Zero : GeneralizedMoneyDto}The JSON emitted for each variant is:
Single-currency ("money"):
{ "kind": "money", "value": 12.34, "currency": "USD"}Multi-currency ("multi"):
{ "kind": "multi", "values": [ { "value": 10.00, "currency": "USD" }, { "value": 5.50, "currency": "EUR" } ]}Zero ("zero"):
{ "kind": "zero"}The kind field appears first in each variant. Do not rely on field ordering in consumers; test that kind is present and correct, then destructure the remaining fields.
Backend: toDto() mapping
Section titled “Backend: toDto() mapping”The DTO type is distinct from the domain type. A toDto() extension function converts the domain GeneralizedMoney into a GeneralizedMoneyDto for the REST response:
fun Money.Value.toDto(): GeneralizedMoneyDto.Money = GeneralizedMoneyDto.Money(value.toDouble(), currency)
fun GeneralizedMoney.toDto(): GeneralizedMoneyDto = when (this) { is Money.Value -> toDto() // single-entry DTO is MultiMoney -> GeneralizedMoneyDto.Multi(values.map { it.toDto() }) is ZeroMoney -> GeneralizedMoneyDto.Zero}Splitting Money.Value.toDto() out as its own extension makes the per-entry mapping explicit and removes the need for a downcast inside the MultiMoney branch — every entry in MultiMoney.values is already a Money.Value, and its toDto() returns the right DTO subtype.
Keep the DTO type in the api/rest layer of the module; the domain type stays in the business layer. They evolve independently — the domain type is free to grow new operations that have no wire representation.
TypeScript Consumer: Tagged Union + Parse Function
Section titled “TypeScript Consumer: Tagged Union + Parse Function”Mirror the discriminator contract with a tagged-union type in TypeScript:
type GeneralizedMoneyDto = | { kind: 'money'; value: number; currency: Currency } | { kind: 'multi'; values: Array<{ value: number; currency: Currency }> } | { kind: 'zero' };Write a parse function that validates the discriminator and converts the DTO into the frontend domain type:
function parseGeneralizedMoneyDto(dto: unknown): GeneralizedMoney { if (typeof dto !== 'object' || dto === null) { throw new Error('parseGeneralizedMoneyDto: not an object'); } const d = dto as Record<string, unknown>; switch (d.kind) { case 'money': return { kind: 'money', value: d.value as number, currency: mapCurrency(d.currency) }; case 'multi': return { kind: 'multi', values: (d.values as Array<unknown>).map((v) => { const e = v as Record<string, unknown>; return { kind: 'money', value: e.value as number, currency: mapCurrency(e.currency) }; }), }; case 'zero': return zeroMoney; default: throw new Error(`parseGeneralizedMoneyDto: unknown kind "${d.kind}"`); }}The default branch throws rather than returning a fallback. An unknown discriminator value means the backend has shipped a new variant that the frontend does not understand yet — silently ignoring it would produce a corrupt domain object.
The round-trip from backend toDto() through JSON to frontend parseGeneralizedMoneyDto works as follows. The backend converts the domain sum type to a DTO with an explicit kind tag; the frontend receives the JSON, branches on kind, and reconstructs the equivalent frontend domain type — throwing on any unrecognized variant to surface version skew immediately rather than silently corrupting data.
Contract-First Discipline
Section titled “Contract-First Discipline”The discriminator key ("kind") and value tokens ("money", "multi", "zero") are public API contract elements. Treat them as you would an endpoint path or an HTTP method: freeze them before parallel BE/FE implementation begins, write them down, and change them only through a coordinated breaking-change process.
The PDEV-662 project’s _docs/api-contract.md is the worked example of this discipline. It was written before either the operations PR or the arda-frontend-app PR was started; both implementations were coded against it. Any deviation discovered during implementation was surfaced to the team-lead session for a coordinated tweak, not silently absorbed on one side.
The contract document should specify:
- The discriminator field name.
- All variant tokens with their exact string values.
- The field shape of each variant (field names, types, nullability).
- Any invariants that span variants (e.g.,
MultiMoneyvaluesis never empty; a single-entry case collapses tomoney).
When to Use This Pattern
Section titled “When to Use This Pattern”Use the kind-discriminator pattern when:
- The sum type is visible to API consumers. If the polymorphism is internal to the backend, default serialization or a hand-rolled serializer is fine.
- The variants are structurally distinct. If all variants share the same fields with different semantics, a status-field approach may be cleaner.
- Parallel BE/FE implementation. Freezing the contract before work starts eliminates integration-time surprises.
- Long-lived API. The explicit token names and the
@SerialNameannotation make schema evolution traceable in code review.
When Not to Use This Pattern
Section titled “When Not to Use This Pattern”Skip this pattern when:
- A single-variant type. If the sealed interface has only one concrete class today, the default serializer is fine and the discriminator adds noise. Introduce the discriminator when a second variant appears.
- Internal representations only. Types used only within a single process (in-memory, test fixtures) do not need wire stability.
- Fine-grained sub-type hierarchies. If the sealed type has many variants all sharing the same fields, consider a flat type with an enum status field instead.
Changing the Contract
Section titled “Changing the Contract”The discriminator key and value tokens are wire-breaking changes if altered. The manual-breaking-change label mechanism described in Proto Release Flow applies here too, mutatis mutandis:
- Coordinate with all consumers before changing a token.
- Prefer an additive expand-migrate-contract approach: add the new variant, migrate consumers, then remove the old variant.
- Document the change under
### Changedor### Removedin the CHANGELOG. - Bump the API version if the endpoint is versioned.
Renaming a token (e.g., "money" → "single") with no other change is indistinguishable from a new-unknown-variant from a client’s perspective: the client’s default branch throws. Plan for a migration window.
See Also
Section titled “See Also”- Money — the
GeneralizedMoneydomain concept that motivated this pattern. - Backend Implementation Patterns — consolidated reference including the Manual Polymorphic JSON Discriminator entry.
- Proto Release Flow — governs breaking-change coordination for shared-schema repos.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved