Search code examples
kotlinserializationdependency-injection

How to write a custom Kotlin serializer with a dependency?


I have a data class structure that I'd like serialize/deserialize. It contains a class, of which I'd like to only serialize an id ignoring the rest of the data, and during deserialization load the rest of the data from a repository using the id. Something like this:

@Serializable
data class Thing(
    val id: String,
    val name: String
// etc.
)

class ThingSerializer(private val thingRepository: ThingRepository) : KSerializer<Thing> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Thing", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: Thing) {
        encoder.encodeString(value.id)
    }

    override fun deserialize(decoder: Decoder): Thing {
        val id = decoder.decodeString()
        return thingRepository.findById(id)!!
    }
}

Is there a way to create an instance of this serializer and configure the SerializationModule to use it?


Solution

  • Note first that Kotlin serialization is designed around serializers that are defined solely at compile time as this provides "type-safety, performance, and avoids reflection usage"1. Hence why the framework is centred around class definitions for serializers.

    Bearing that in mind, there are two ways of solving the problem of providing parameters to a serializer:

    1. Define your serializer in a class with a zero-argument constructor that receives your dependency (here ThingRepository) statically; or
    2. Use contextual serialization, the feature used for serializers not defined at compile time.

    1. Providing the dependency statically

    With this approach you could write your serializer with a static field instead, ie:

    class ThingSerializer : KSerializer<Thing> {
    
        companion object {
            var thingRepository: ThingRepository? = null
        }
    
        override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Thing", PrimitiveKind.STRING)
    
        override fun serialize(encoder: Encoder, value: Thing) {
            encoder.encodeString(value.id)
        }
    
        override fun deserialize(decoder: Decoder): Thing {
            val id = decoder.decodeString()
            return thingRepository!!.findById(id)!!
        }
    }
    

    You can tell the plugin about the serializer by annotating the target class:

    @Serializable(with = ThingSerializer::class)
    data class Thing
    

    You would also have to set the static field prior to serialization:

    
    ThingSerializer.thingRepository = thingRepository
    

    2. Contextual serialization

    This approach allows you to register a serializer instance in the serializers module, rather than attach a class, like so:

    val serializersModule = SerializersModule {
        contextual(ThingSerializer(thingRepository))
    }
    

    You also need to tell the plugin at compile time that you want to use the contextual serializer for that class. There are several ways to do that, depending on your taste:

    1. Annotating the original class

    This works but the compiler raises a warning because you are using a serializer for type Any on a specific class. Hence it's necessary to suppress the warning:

    @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
    @OptIn(ExperimentalSerializationApi::class)
    @Serializable(with = ContextualSerializer::class)
    data class Thing
    
    1. Annotate each usage with @Contextual

    If you prefer not have to suppress a warning, you can annotate every occurrence of your target class with @Contextual when it occurs. Of course the downside of that is you have to include that annotation everywhere.

    @Serializable
    class ThingUser(@Contextual private val thing: Thing)
    
    1. Annotate each file with usage with @UseContextualSerialization

    There is also a file-level annotation you can use:

    @file:UseContextualSerialization(Thing::class)
    
    package com.thing
    
    class ThingUserTwo(private val thing: Thing)
    

    Finally, you would have to remember to provide the serializers module when you create your Json instance (or equivalent):

    val json = Json { serializersModule = serializersModule }
    

    1See the serialization KEEP. Presumably the framework instantiates the serializers at compile time when performing code generation.