Sérialiser les sealed classes Kotlin avec Jackson : causes, solutions, arbitrages

Publish date: 19 Dec 2025
Tags: kotlin jackson serialization

Pour qui / Quand / Résultat / Commande

  • Pour qui : Développeurs Kotlin/JVM (Spring Boot, Ktor, etc.) confrontés à des erreurs de (dé)sérialisation de sealed classes avec Jackson.
  • Quand : Dès qu’une sealed class ou hiérarchie polymorphe doit être (dé)sérialisée en JSON côté API ou persistence.
  • Résultat : Comprendre pourquoi ça casse, appliquer un fix rapide, arbitrer une refacto si besoin, valider par des tests reproductibles.
  • Commande :
./gradlew test

TL;DR #

Jackson ne gère pas nativement la (dé)sérialisation polymorphe des sealed classes Kotlin. Sans configuration adaptée, la désérialisation échoue ou perd le sous-type. Ce guide explique pourquoi, comment le reproduire, et propose des solutions rapides (module Kotlin, annotations, registration manuelle) ainsi qu’une alternative architecturale (séparer DTO/domain). Tests fournis pour valider chaque approche.

1. Intro & cas d’usage #

Les sealed classes Kotlin sont idéales pour modéliser des hiérarchies fermées (ex : résultats, événements, commandes). Mais lors de la (dé)sérialisation JSON avec Jackson, le sous-type n’est pas toujours retrouvé, menant à des erreurs ou à la perte d’information. Exemple typique :

sealed class Command {
	data class Create(val name: String) : Command()
	data class Delete(val id: Int) : Command()
}

2. Reproduction minimale (code) #

Projet minimal (Gradle) #

dependencies {
    val jacksonVersion = "2.18.2"
    
    testImplementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
    testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
    testImplementation(kotlin("test"))
}

Command.kt :

sealed class Command {
	data class Create(val name: String) : Command()
	data class Delete(val id: Int) : Command()
}

CommandTest.kt :

package scenario01_notyping

import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
import lab.ObjectMappers
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

sealed interface Event {
    val timestamp: Long
}

data class UserCreated(
    override val timestamp: Long,
    val userId: String,
    val email: String
) : Event

data class UserDeleted(
    override val timestamp: Long,
    val userId: String
) : Event

class Scenario01_NoTypingTest {

    private val json = """
        {
            "timestamp": 1702989600000,
            "userId": "user-123",
            "email": "alice@example.com"
        }
    """.trimIndent()

    @Test
    fun `sealed interface without type info - deserialize - fails with missing type id`() {
        val mapper = ObjectMappers.kotlin()

        val exception = assertThrows<InvalidDefinitionException> {
            mapper.readValue(json, Event::class.java)
        }

        assert(exception.message?.contains("cannot construct instance") == true ||
               exception.message?.contains("abstract types") == true ||
               exception.message?.contains("no Creators") == true)
    }
}

Commande de test :

./gradlew test --tests CommandTest

3. Pourquoi ça casse ? #

Limite importante : ce module ne règle pas à lui seul le problème du polymorphisme des sealed classes. Il est nécessaire mais pas suffisant si vous attendez de Jackson qu’il retrouve automatiquement le bon sous-type lors de la désérialisation.

Quand l’utiliser ?

4. Solutions rapides #

a) Ajouter le module Kotlin #

Pour activer la désérialisation polymorphe, il faut indiquer à Jackson comment distinguer les sous-types dans le JSON. Cela se fait via les annotations @JsonTypeInfo (pour choisir le discriminant) et @JsonSubTypes (pour lister les sous-types connus).

Principe :

Avantages :

Limites :

fun kotlin(): ObjectMapper = ObjectMapper().registerKotlinModule()

b) Annotations @JsonTypeInfo + @JsonSubTypes #

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
	JsonSubTypes.Type(value = Command.Create::class, name = "create"),
	JsonSubTypes.Type(value = Command.Delete::class, name = "delete")
)
sealed class Command { ... }
package scenario02_property

import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import lab.ObjectMappers
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes(
    JsonSubTypes.Type(value = OrderCreated::class, name = "OrderCreated"),
    JsonSubTypes.Type(value = OrderShipped::class, name = "OrderShipped"),
    JsonSubTypes.Type(value = OrderCancelled::class, name = "OrderCancelled")
)
sealed interface OrderEvent {
    val orderId: String
}

data class OrderCreated(
    override val orderId: String,
    val customerId: String,
    val totalAmount: Double
) : OrderEvent

data class OrderShipped(
    override val orderId: String,
    val trackingNumber: String
) : OrderEvent

data class OrderCancelled(
    override val orderId: String,
    val reason: String
) : OrderEvent

class Scenario02_PropertyTest {

    private val jsonCreated = """
        {
            "type": "OrderCreated",
            "orderId": "order-456",
            "customerId": "cust-789",
            "totalAmount": 299.99
        }
    """.trimIndent()

    private val jsonShipped = """
        {
            "type": "OrderShipped",
            "orderId": "order-456",
            "trackingNumber": "TRACK123456"
        }
    """.trimIndent()

    @Test
    fun `sealed with JsonTypeInfo PROPERTY - deserialize OrderCreated - succeeds`() {

Une alternative aux annotations consiste à enregistrer explicitement les sous-types auprès du ObjectMapper. Cela centralise la configuration et évite de polluer le code métier avec des annotations Jackson.

Principe :

Avantages :

Limites :

c) Registration manuelle des sous-types #

val mapper = jacksonObjectMapper()
mapper.registerSubtypes(Command.Create::class.java, Command.Delete::class.java)
package scenario03_existingprop

import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import lab.ObjectMappers
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.EXISTING_PROPERTY,
    property = "kind"
)
@JsonSubTypes(
    JsonSubTypes.Type(value = EmailNotification::class, name = "email"),
    JsonSubTypes.Type(value = SmsNotification::class, name = "sms"),
    JsonSubTypes.Type(value = PushNotification::class, name = "push")
)
sealed interface Notification {
    val kind: String
    val recipientId: String
}

data class EmailNotification(
    override val kind: String = "email",
    override val recipientId: String,
    val subject: String,
    val body: String
) : Notification

data class SmsNotification(
    override val kind: String = "sms",
    override val recipientId: String,
    val phoneNumber: String,
    val message: String
) : Notification

data class PushNotification(
    override val kind: String = "push",
    override val recipientId: String,
    val deviceToken: String,
    val title: String,
    val message: String
) : Notification

class Scenario03_ExistingPropTest {

    private val jsonEmail = """
        {
            "kind": "email",
            "recipientId": "user-001",
            "subject": "Welcome!",
            "body": "Thank you for signing up."
        }
    """.trimIndent()

    private val jsonSms = """
        {
            "kind": "sms",

5. Solution pérenne : séparer DTOs / domain #

Dans les architectures robustes, il est recommandé de ne pas exposer directement les sealed classes du domaine métier à la (dé)sérialisation JSON. À la place, on crée des DTOs (Data Transfer Objects) simples, adaptés à l’API, puis on effectue un mapping explicite entre ces DTOs et les classes du domaine.

Pourquoi cette approche ?

Quand la privilégier ?

Limite : nécessite d’écrire du code de mapping (souvent simple mais parfois fastidieux).

package scenario05_envelope

import lab.ObjectMappers
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

sealed interface Command {
    val commandId: String
}

data class CreateAccountCommand(
    override val commandId: String,
    val accountName: String,
    val initialBalance: Double
) : Command

data class TransferMoneyCommand(
    override val commandId: String,
    val fromAccount: String,
    val toAccount: String,
    val amount: Double
) : Command

data class CloseAccountCommand(
    override val commandId: String,
    val accountId: String,
    val reason: String
) : Command

data class CommandEnvelopeDto(
    val commandType: String,
    val commandId: String,
    val payload: Map<String, Any>
)

object CommandMapper {
    fun toCommand(dto: CommandEnvelopeDto): Command {
        return when (dto.commandType) {
            "CreateAccount" -> CreateAccountCommand(
                commandId = dto.commandId,
                accountName = dto.payload["accountName"] as String,
                initialBalance = (dto.payload["initialBalance"] as Number).toDouble()
            )
            "TransferMoney" -> TransferMoneyCommand(
                commandId = dto.commandId,
                fromAccount = dto.payload["fromAccount"] as String,
                toAccount = dto.payload["toAccount"] as String,
                amount = (dto.payload["amount"] as Number).toDouble()
            )
            "CloseAccount" -> CloseAccountCommand(
                commandId = dto.commandId,
                accountId = dto.payload["accountId"] as String,
                reason = dto.payload["reason"] as String
            )
            else -> throw IllegalArgumentException("Unknown command type: ${dto.commandType}")
        }
    }
}

6. Bonus : serializer custom & alternatives #

7. Checklist & tests à exécuter #

Cette checklist garantit que chaque solution proposée a été validée par un test automatisé, et que le guide reste à jour même en cas d’évolution des dépendances. Elle sert aussi de référence rapide pour vérifier la robustesse de vos propres implémentations.

Decision tree : quelle solution choisir ? #

CritèreSolution rapideSolution pérenne (DTO)Custom/Alternative
Projet existant, peu de sous-typesAnnotations/registration
Projet long terme, API publiqueDTO + mapping
Besoin multiplateformekotlinx.serialization
Besoin de contrôle fin sur JSONSerializer custom

Pièges, limites, alternatives #

Checklist de sortie #


Tester sur ta machine (lab reproductible) #

cd external/herdev-labs/kotlin/jackson-and-sealed-class
./gradlew test

TODO Créer une release ZIP (voir README du lab)

Ce laboratoire contient tous les scénarios évoqués dans ce guide, sous forme de tests automatisés. Chaque solution présentée ici correspond à un ou plusieurs tests du lab, ce qui permet de vérifier concrètement le comportement de Jackson selon la configuration choisie.

À quoi s’attendre ?


Références officielles #

Changelog #