Mastodon Icon GitHub Icon LinkedIn Icon RSS Icon

Polymorphic Class Serialization in Spring and MongoDB

Let’s start from the beginning: I am not an expert in Spring and don’t particularly like it. I always feel like I need to throw decorators at the code until I find some magic spell that makes my code work. And I don’t particularly appreciate how all these decorators and reflection voodoo move complexity from compile time to runtime errors.

Nevertheless, it is widely used and objectively a solid choice to build a backend application with minimal effort (I don’t like Spring, but I am not blind). Furthermore, I have been using it at work for 5 years, and as they say, I prefer the devil I know.

Anyway, Spring is all fun and games as long as you do what the framework expects. However, if you want to do something a bit different, you find yourself drowning in a sea of Configs, Readers, custom mappers, mysterious Beans, and Adapters, and 50 pages of documentation or blog posts describing 50 different options to achieve the same result.

That happened last week while I was solving a simple work problem. In fact, our Spring application uses MongoDB as a database backend, and one of the documents in the MongoDB database had to contain a polymorphic object. It seemed like an easy task, but I ended up losing at least a full day on it.

So, I am writing this as a reminder for my future self.

But let’s start from the very beginning.

The Problem

Let’s look at an example of a polymorphic model.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Document
data class Zoo(
    val animals: List<Animal> = listOf()
)

sealed interface Animal

data class Dog(
    val barkSound: String
) : Animal

data class Cat(
    val meowVolume: Int
) : Animal

In the example, Zoo is a Document in MongoDB handled by spring-data and contains a list of Animal instances. An Animal instance can be of two types:

  1. A Dog, corresponding to the following JSON object { "barkSound": "wof" }.
  2. A Cat, corresponding to the following JSON object { "meowValume": 100 }.

As you can see, Animal is a polymorphic class: it can be either a Dog or a Cat depending on the properties it contains. And that’s the problem: when loading this data from the database (or from the JSON payload), Spring has no idea which concrete instance to instantiate. It is our job to say to the deserializer that ”if it contains barkSound then is a Dog; otherwise, if it has meowValume, it is a Cat”.

Yes. But where?

And that’s the second issue: we need to solve this ambiguity in two places.

  1. At the Spring-Data Level, that is, when loading and saving the class from/to the MongoDB database.
  2. At the API/Resource Level, that is, when Jackson serializes/deserializes the JSON payload of the API (for example, a PATCH to /api/zoo).

In theory, there is a single way to solve both levels. However, I have tried it (multiple versions of it), and it didn’t work.

So, I decided to solve the two levels one at a time.

Solve the Spring-Data Level

The suggested way to handle polymorphism is to use a TypeAlias. This decorator is used to provide an alias for a class. For example, if you are tired of writing the entire class name in Spring decorators (e.g., come.whatever.package.Foo), you can annotate the Foo class with @TypeAlias("Foo") and use only Foo from now on.

Why is this useful for polymorphism? Because this instructs Spring to implicitly include the class in the serialized document in a “hidden” _class property.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Document
data class Zoo(
    val animals: List<Animal> = listOf()
)

sealed interface Animal

@TypeAlias("dog")
data class Dog(
    val barkSound: String
) : Animal

@TypeAlias("cat")
data class Cat(
    val meowVolume: Int
) : Animal

According to this example, the documents in MongoDB will be saved as: { "_class": "dog", "barkSound": "wof!" }. By default, Spring passes the value of _class into a TypeInformationMapper to obtain the correct instantiation type.

Add default instantiation

This, however, was not enough in my case. In fact, to ensure backward compatibility, I needed to specify a default instantiation. In other words, if a document doesn’t have the _class property, I want Spring to use the default type Dog.

As far as I know, there is no automatic way to accomplish this. Therefore, the solution I found was to write a Converter to manually define the mapping between _class and the concrete class.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@ReadingConverter  
class AnimalReadingConverter : Converter<Document, Animal> {  
    override fun convert(source: Document): Animal = when (source.getString("_class")) {  
        "dog" -> {  
             Dog(barkSound = source.getString("barkSound"))  
        }  
        null -> {  
             Dog(barkSound = source.getString("barkSound"))   
        }  
        "cat" -> {  
            Cat(meowVolume = source.getInteger("meowVolume"))    
        }  
        else -> throw IllegalArgumentException("Unknown ConvNodeCondition type")  
    }  
}

We are almost there. The last thing we need to do is connect AnimalReadingConverter to Mongo. We do this through the MongoConfig configuration class.

1
2
3
4
5
6
7
8
9
@Configuration
class MongoConfig {
    @Bean
    fun mongoCustomConversions(): MongoCustomConversions {
        val converterList: MutableList<Converter<*, *>> = ArrayList()
        converterList.add(AnimalReadingConverter())
        return MongoCustomConversions(converterList)
    }
}

Solve the API Level

After I tried this solution, saving and loading classes from MongoDB worked perfectly. However, I soon encountered an issue: serialization and deserialization don’t work when loading/writing the payload from/to an API call.

For this, I also needed to address the problem with Jackson serialization. Luckily, (or not, depending on your tastes) we can solve this by using – you guessed it – more decorators.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Document
data class Zoo(
    val animals: List<Animal> = listOf()
)

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "_class",
    defaultImpl = Dog::class,
    visible = true
)
@JsonSubTypes(
    JsonSubTypes.Type(value = Dog::class, name = "dog"),
    JsonSubTypes.Type(value = Dog::class, name = "dog")
)
sealed interface Animal

@TypeAlias("dog")
@JsonTypeName("dog")
data class Dog(
    val barkSound: String
) : Animal

@TypeAlias("cat")
@JsonTypeName("cat")
data class Cat(
    val meowVolume: Int
) : Animal

The @JsonTypeInfo and @JsonSubTypes are Jackson’s decorators used to handle polymorphism. The decorator’s parameters are:

  • use = JsonTypeInfo.Id.NAME means that it needs to use the class name as ID (as specified by @JsonTypeName).
  • include = JsonTypeInfo.As.PROPERTY means that it has to store the type discriminator as a property.
  • property = "_class" specifies the name of such a property. We could use "type" or whatever we like, but for conformity with TypeAlias I decided to use _class as well.
  • defaultImpl = Dog::class means that, if there is no _class, then we default to Dog.
  • visible = true makes the property visible in the serialized JSON (otherwise, the property is only used as a discriminant during deserialization).

After this ocean of decorators, I was able to use the APIs without problems.

Conclusions

In theory, we could use only TypeAlias or the Jackson’s JsonTypeInfo decorator. However, I had no luck with either of them. Perhaps there was some misconfiguration in our application, but I wasn’t able to make them work correctly.

The solution I described in this article may be redundant, but at least it works.

If you are currently facing a similar problem, I hope I provided you with some helpful hints.

Now, if you don’t mind, I have several more Spring problems in my backlog.

comments powered by Disqus