Skip to content

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.

Consider a Kotlin sealed interface with two or more structurally distinct variants:

@Serializable
sealed 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.

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.

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.

PlantUML diagram

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., MultiMoney values is never empty; a single-entry case collapses to money).

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 @SerialName annotation make schema evolution traceable in code review.

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.

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:

  1. Coordinate with all consumers before changing a token.
  2. Prefer an additive expand-migrate-contract approach: add the new variant, migrate consumers, then remove the old variant.
  3. Document the change under ### Changed or ### Removed in the CHANGELOG.
  4. 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.

  • Money — the GeneralizedMoney domain 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