Expedia Group Technology — Engineering
Polymorphic Deserialization in Spring Data MongoDB: A Reactive approach
Seamlessly managing dynamic type handling for reactive applications
In this blog, we’ll delve into the complexities of polymorphic deserialization and explore how to tackle them effectively. We’ll navigate through the nuances of Spring Data MongoDB and learn how to leverage the capabilities of ReactiveMongoTemplate to address these challenges.
The scenario
Let’s first introduce a simple data class. Imagine we have a MongoDB collection storing products, each with a different type of attribute. Here’s a simple data class:
data class Product(
val _id: String,
val attributes: List<Attribute>
)
sealed class Attribute {
@TypeAlias("NumericAttribute")
data class NumericAttribute(
val value: Int,
val unit: String
) : Attribute()
@TypeAlias("TextAttribute")
data class TextAttribute(
val description: String,
val language: String
) : Attribute()
}
So, what exactly is a polymorphic object? Simply put, it’s an object that can take on different forms or types. In our case, the attributes
field in the Product
class can hold different types of attributes, such as numeric attributes, text attributes, or even more complex types.
Basic configuration for ReactiveMongoTemplate
Before addressing the polymorphic deserialization issue, let’s set up a basic configuration for ReactiveMongoTemplate
. This configuration provides a foundation for working with MongoDB in a reactive Spring application:
@Configuration
@EnableConfigurationProperties(MongoProperties::class)
class ReactiveMongoConfiguration(
val mongoProperties: MongoProperties
) : AbstractReactiveMongoConfiguration() {
override fun getDatabaseName(): String {
return mongoProperties.database
}
override fun reactiveMongoClient(): MongoClient {
return MongoClients.create(mongoProperties.uri)
}
@Bean
override fun reactiveMongoTemplate(
reactiveMongoDatabaseFactory: ReactiveMongoDatabaseFactory,
): ReactiveMongoTemplate {
return ReactiveMongoTemplate(reactiveMongoDatabaseFactory)
}
}
Using this bean of reactiveMongoTemplate
we can easily save data to our collection. This is what the saved data looks like:
{
"_id": "product_id",
"attributes": [
{
"_class": "NumericAttribute",
"value": 42,
"unit": "kg"
},
{
"_class": "TextAttribute",
"description": "High quality",
"language": "en"
}
]
}
Now, here’s where the challenge arises. When we store these Product
objects in MongoDB and later retrieve them, how does Spring Data MongoDB know how to deserialize the attribute
field correctly? This is where polymorphic object deserialization comes into play.
The error
When working with polymorphic objects in Spring Data MongoDB, you might encounter a deserialization error similar to the one below:
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.models.Attribute]: Is it an abstract class?; nested exception is java.lang.InstantiationException
When Spring Data MongoDB tries to deserialize this document into a Product
object, it sees the attribute
field and its properties. However, it doesn't inherently know to instantiate Attribute$NumericAttribute
because the type information (_class
field) isn't utilized by default.
Why this happens
This problem typically manifests when attempting to retrieve a document before it has been saved, meaning the type alias mapping is not yet cached. When we save the document first, its alias-to-type mapping gets cached. Consequently, when we attempt to retrieve the document afterward, it deserializes correctly because the mapping is already cached.
However, if we try to retrieve the document before saving it, we encounter an exception. The mapper cannot deduce the type based solely on the alias. It tries to use the type of the field in the document, which in our case is Attribute
, a sealed class.
Cache dependency
This issue highlights a critical dependency on the alias-to-type mapping cache. The application only works correctly until the next deployment. Once we deploy a new instance, the application no longer has the mapping in the cache to resolve the type, leading to deserialization failures until the document is saved again and the mapping is cached.
MongoDB does not enforce schema constraints, and Spring Data MongoDB relies on type information to correctly map JSON documents to their corresponding classes. Without explicit type indicators or a custom conversion mechanism, the deserialization process fails when encountering polymorphic fields.
The solution
Here’s the catch: MongoDB needs a little guidance to turn those stored documents back into your Kotlin or Java objects, especially when they’re polymorphic. It’s like needing a map to navigate through unfamiliar terrain.
Enter the converter
And that’s where our converter class comes into play! This handy tool swoops in and saves the day by translating those MongoDB documents into your Kotlin or Java objects. It acts as a bridge, ensuring seamless communication between your application and MongoDB.
To handle polymorphic deserialization in Spring Data MongoDB with ReactiveMongoTemplate
, we need to implement a custom converter that can correctly instantiate the specific subclass based on a type indicator stored in the document. Here's how you can do it:
Manual converter
We’ll start with a manual approach to convert MongoDB documents into our Kotlin objects.
@ReadingConverter
class AttributeConverter : Converter<Document, Attribute?> {
override fun convert(source: Document): Attribute? {
return when (source.getString("_class")) {
"NumericAttribute" -> {
val value = source.getInteger("value")
val unit = source.getString("unit")
Attribute.NumericAttribute(value, unit)
}
"TextAttribute" -> {
val description = source.getString("description")
val language = source.getString("language")
Attribute.TextAttribute(description, language)
}
else -> null
}
}
}
This converter takes a MongoDB Document
as input and manually maps its fields to our Attribute
sealed class. We check the _class
field to determine the concrete type of the object and create instances accordingly.
Why manual conversion falls short
It’s not the best approach for complex objects. Imagine having dozens of fields or nested objects — it quickly becomes cumbersome and error-prone to manually map each field.
There’s a better way to handle this — using an object mapper like Jackson. With Jackson, we can automate the conversion process, making our code cleaner, more maintainable, and less error-prone.
Leveraging Jackson for deserialization
Now, let’s kick it up a notch and use Jackson to automate the conversion process.
@ReadingConverter
class AttributeReadConverter(private val objectMapper: ObjectMapper) : Converter<Document, Attribute?> {
override fun convert(source: Document): Attribute? {
val classType = source.getString("_class")
?: throw IllegalArgumentException("Class type not found in document")
return when (classType) {
"NumericAttribute" -> objectMapper.convertValue(source, Attribute.NumericAttribute::class.java)
"TextAttribute" -> objectMapper.convertValue(source, Attribute.TextAttribute::class.java)
else -> throw IllegalArgumentException("Unknown class type: $classType")
}
}
}
@Bean
fun objectMapper(): ObjectMapper {
return ObjectMapper()
.registerModule(KotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
The final piece: Solving the puzzle
After carefully crafting our converter and ensuring seamless integration with Spring Data MongoDB, it’s time to bring it all together. In this final section, we’ll insert our custom converter into the mix and wrap up our configuration to fully support the mapping of polymorphic objects.
Here is the updated mongo config:
@Configuration
@EnableConfigurationProperties(MongoProperties::class)
class ReactiveMongoConfiguration(
val mongoProperties: MongoProperties
) : AbstractReactiveMongoConfiguration() {
override fun getDatabaseName(): String {
return mongoProperties.database
}
override fun reactiveMongoClient(): MongoClient {
return MongoClients.create(mongoProperties.uri)
}
override fun customConversions() = MongoCustomConversions(
listOf(
AttributeReadConverter() // here we have inserted the converter
)
)
@Bean
override fun reactiveMongoTemplate(
reactiveMongoDatabaseFactory: ReactiveMongoDatabaseFactory,
): ReactiveMongoTemplate {
// Create custom conversions
val conversions = customConversions()
// Set up mapping context
val context = MongoMappingContext()
context.setSimpleTypeHolder(conversions.simpleTypeHolder)
context.afterPropertiesSet()
// Create mapping converter
val converter = MappingMongoConverter(
ReactiveMongoTemplate.NO_OP_REF_RESOLVER, // No operation resolver for ReactiveMongoTemplate
context
)
converter.setCustomConversions(conversions)
converter.setCodecRegistryProvider(databaseFactory) // Set codec registry provider
converter.afterPropertiesSet()
// Finally, configure the converter in ReactiveMongoTemplate
return ReactiveMongoTemplate(reactiveMongoDatabaseFactory, converter)
}
}
- Custom Conversions Setup: We begin by creating custom conversions, including our special converter for polymorphic objects. These conversions handle deserialization.
- Mapping Context Initialization: Next, we establish a MongoMappingContext to maintain type information during mapping. Our custom conversions are integrated here.
- Mapping Converter Creation: We craft a MappingMongoConverter responsible for translating MongoDB documents. It’s configured with our custom conversions and mapping context.
- Codec Registry Provider Configuration: To ensure compatibility with our MongoDB database factory, we configure the converter with a codec registry provider.
- Configuring ReactiveMongoTemplate: Finally, we tie everything together by configuring our ReactiveMongoTemplate with the database factory and mapping converter. Our solution is now set to handle polymorphic objects seamlessly.
With our setup complete, our Reactive MongoDB database is ready to tackle complex data structures effortlessly.