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.
|
|
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:
- A
Dog
, corresponding to the following JSON object{ "barkSound": "wof" }
. - 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.
- At the Spring-Data Level, that is, when loading and saving the class from/to the MongoDB database.
- 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.
|
|
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.
|
|
ℹ️ Note
This solution is far from perfect. For example, it necessitates manual intervention each time we modify the Dog
and Cat
classes. I am aware that there exists a superior solution; however, in my case, the complexity of the general implementation was hardly justifiable.
We are almost there. The last thing we need to do is connect AnimalReadingConverter
to Mongo. We do this through the MongoConfig
configuration class.
|
|
ℹ️ Note
We don’t need to implement a @WritingConverter
class. After all, when we serialize the class, we already know what class it is.
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.
|
|
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 withTypeAlias
I decided to use_class
as well.defaultImpl = Dog::class
means that, if there is no_class
, then we default toDog
.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.