MongoDB is really flexible in terms of what can it store. We don't have to tie to specific schema like it is with relational DBs. As our model changes in time, it is not a big deal for Mongo to adjust to it. It makes it easier to design application from the domain perspective, rather than from data perspective. Having this in mind, we would like to store different structures within our table through the code. Official Micronaut Guide is a good place to start, but it took me a while to learn how to store in Mongo objects containing other objects. Here is the solution.
Foundation
In this case I will extend a bit application I've presented in my previous post on integration testing. I would like to create dictionary structure, which will hold word in one language together with its translations. To achieve that I've prepared following structure:
data class Word(val word: String, val translations: List<Translation>)
data class Translation(val language: String, val translation: String)
If we would like to store it within relational database, by default it would require two tables - one for Word
rows, and one for Translation
rows with reference to particular Word
. Mongo by default allow us to both of these objects within one table. It will contain a Word
together with list of Translation
in JSON format as a separate field.
In terms of Mongo, above setup is easily achievable. We can create table with two fields word
and translations
where first one will be string value, and the latter one will be JSON containing list of objects having language
and translation
fields.
Serde
Micronaut comes with dedicated plugin called micronaut-serde-processor
enabling us to serialize and deserialize. We can annotate class with @Serdeable
annotation to mark it as the one which will be exchanged in the future. As we are not using micronaut-data
which can make things easier (but I was not able to achieve such nested serialization) we will need to rely on manual poinitng how to serialize to and from BSON
fields used by Mongo. To enable classes being manipulated such way, we also need to add @Introspected
annotation.
As mentioned previously, we will have to point out how to convert our entities. The easiest way would be to do that through the constructor. For it to work, we need to mark our constructor with @Creator
and @BsonCreator
annotations. Our entity will be converted through the constructor, containing all required fields. For proper conversion, we need as well to show which fields will be taken into consideration. Each one of them needs to be annotated by @field:BsonProperty("name")
and @param:BsonProperty("name")
annotations. This is to mark the property as both class (field
) and constructor(param
) properties. Having such prepared class, we do not have to worry about declaration of setters and getters being by default key for serialization process. Our classes will look like that:
- MongoWord
@Introspected
@Serdeable
data class MongoWord @Creator @BsonCreator constructor(
@field:BsonProperty("word") @param:BsonProperty("word") val word: String,
@field:BsonProperty("translations") @param:BsonProperty("translations") val translations: List<MongoTranslation>
)
- MongoTranslation
@Introspected
@Serdeable
data class MongoTranslation @Creator @BsonCreator constructor(
@field:BsonProperty("language") @param:BsonProperty("language") val language: String,
@field:BsonProperty("translation") @param:BsonProperty("translation") val translation: String
)
Separation of Domain and Entity
It is good practice to separate classes used within our domain logic from the ones being used to communicate with outer world. I like the ability to quickly convert each way eg. through static factory method. In Kotlin we can achieve that using companion object. Such object will look like following for our Word class:
companion object {
fun fromWord(word: Word): MongoWord {
return MongoWord(word.word, word.translations.map { MongoTranslation.fromTranslation(it) })
}
}
When we want to create domain object straight out of our entity, we can use method being executed on the instance
fun toWord(): Word {
return Word(word, translations.map { it.toTranslation() })
}
Having this methods within transport classes will allow us to hide implementation details from domain Word and Translation object. Thanks to this we can focus on actual business logic, without thinking how our objects should be serialized and deserialized.
Repository
Having everything prepared there is nothing else than building an repository. This will be a Singleton, which will accept and return Word object. As fields used to build it we need MongoClient
together with names of database and collection which we will operate on. Then all we have to do, is to implement methods responsible for storing and getting Words from repository. Below is code showing how we can achieve that.
@Singleton
class WordRepositoryMongo(
mongoClient: MongoClient,
@Property(name = "word.database") databaseName: String,
@Property(name = "word.collection") collectionName: String
) : WordRepository {
private val collection: MongoCollection<MongoWord>
init {
val db = mongoClient.getDatabase(databaseName)
collection = db.getCollection(collectionName, MongoWord::class.java)
}
override fun findWord(word: String): Word? {
return collection.find(eq("word", word)).firstOrNull()?.toWord()
}
override fun putWord(word: Word) {
collection.insertOne(MongoWord.fromWord(word))
}
}
Tests
Testcontainers
are really powerful tool which allows us to test all the code we have just written against actual MongoDB instance. Thanks to micronaut io.micronaut.test-resources
plugin, the only thing we need to do is to provide dependency to Testcontainers
and everything would plug in out-of-the-box. No configuration needed. Before writing test, we need to make sure that with each execution the DB state will be cleared. To do this, we can do following:
@BeforeEach
fun beforeEach() {
mongoClient.getDatabase(databaseName)
.getCollection(collectionName)
.deleteMany(Document())
}
It uses injected MongoClient
like we use it in WordRepositoryMongo
class. From collection declared as class field, we will delete all existing documents. When we have it prepared, then we can execute the sample test.
@Test
fun shouldStoreWordInRepository() {
//Given
val word = Word(
"hello", listOf(
Translation("polish", "czesc"),
Translation("deutsch", "hallo")
)
)
//When
repository.putWord(word)
//Then
val wordFromRepository = repository.findWord("hello")
Assertions.assertTrue(wordFromRepository != null)
Assertions.assertTrue(wordFromRepository!!.translations.size == 2)
Assertions.assertTrue(wordFromRepository!!.translations
.filter { it.language == "polish" && it.translation == "czesc" }
.size == 1)
Assertions.assertTrue(wordFromRepository!!.translations
.filter { it.language == "deutsch" && it.translation == "hallo" }
.size == 1)
}
It tests if word put could be reached out later.
Conclusion
It was not an easy job for me to find how to store object structure as parameter for Mongo table. Micronaut is still not so popular as Spring, so the community support is not yet so active. I hope that this article could help you design tables which will realise full potential of domain, without need to think about configuration nitpicks.
All the code used in this article you can find here within hello
package.
Top comments (0)