Chapter 2: Koin and KMongo
Kotlin Multiplatform Mobile
link Koin and KMongo
Introduction
Now that we have created a working Ktor GraphQL service, it's time to introduce our backend service's real architecture. This service will be organized into four components, which will interact with one another over a shared network. The goal here is to keep our backend service organized and modular, which supports testing and making changes on the fly.
We are creating what is known as "Service Oriented Architecture," albeit on a much smaller scale.
Service-oriented architecture aims to build applications more quickly in response to new business opportunities.
Source: IBM
Let's take a look at how this applies to our project with the following diagram:
At every level, each module acts as an independent part of the overall application. These modules rely on external libraries to perform operations and power the application. Once we start to build services, this process will become easier to understand.
So far, we already covered Ktor and KGraphQL. Now it is time to introduce Koin and KMongo.
link Insert Koin
Koin is a Dependency Injection library that is both simple and effective. Let's take this opportunity to understand Koin and Dependency Injection in more detail.
Why DI?
Dependency injection, or DI allows developers to call modules only when they are needed. As they say in Hollywood, "Don't Call Us, We'll Call You."
When using DI it is important to bear in mind the different types of injection. Let's see some examples:
- No DI
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
Source: Dependency injection in Android
In the above example, we initialize an instance of the Car
class's Engine
each time it is created.
The problem with this implementation is that the Engine
becomes tightly coupled to the Car
class, and will prevent us from testing the Car
class in isolation. We cannot create a mock Engine
class for unit testing.
- Manual DI
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
Source: Dependency injection in Android
In the above example, the app creates an instance of Engine
and uses it construct an instance of Car
. This is known as Constructor Injection, because we pass the dependencies of a class to its constructor.
- DI Library
Based on whichever DI library you choose, your milage may vary. In our backend project, we will utlize Koin, so the Car
sample would look like the following:
/**
* Car Class
* use Engine
*/
class Car : KoinComponent {
// Inject Engine
val engine: Engine by inject()
// display our data
fun start() {
engine.start()
}
}
With Manual DI or a DI library, we can inject modules and remove tightly-coupled classes.
The following graph illustrates the relationship between Injectors, Clients and Services.
In our backend project, Koin will act as the injector and provide the networking client for all of our services.
Benefits of DI
Some of you may be wondering what are the benefits of this approach, so here they are:
- Testability: Objects are isolated from their dependencies
- Readability: DI can create patterns with less boilerplate
- Flexibility: Reusable modules are extensible and testable
- Maintainability: Easier to test modules and collaborate
Why Koin?
Koin provides us with the ability to describe our app, instead of annotating or generating code for it.
Getting started with Koin is a two-step process.
- Start a
KoinApplication
usingstartKoin
:
// start a KoinApplication in Global context
startKoin {
// declare used logger
logger()
// declare used modules
modules(coffeeAppModule)
}
Source: Starting Koin
In the above code, startKoin
will initialize an entry point for all of our application modules
and register it in Koin's GlobalContext
.
- Use the module function to declare a Koin module. A Koin module is the space to declare all your components:
val mainModule = module {
// your dependencies here
}
We will use Koin to initialize a global client for our database, powered by MongoDB Atlas. Before getting started with Koin, let's introduce KMongo.
link KMongo
We will use KMongo to handle connecting to a live database. KMongo allows us to retrieve or modify data stored in a database provided by MongoDB Atlas.
In order to proceed, first you will need to create a free MongoDB Atlas account.
MongoDB Atlas
We recommend creating a free tier database cluster at MongoDB Atlas. Sign up for MongoDB Atlas and follow this simple tutorial to create a free cluster.
MongoDB Atlas is the global cloud database service for modern applications. Deploy fully managed MongoDB across AWS, Azure, or GCP. Best-in-class automation and proven practices guarantee availability, scalability, and compliance with the most demanding data security and privacy standards. Use MongoDB’s robust ecosystem of drivers, integrations, and tools to build faster and spend less time managing your database.
We are going to be using an instance of the MongoDB Atlas database.
- Navigate to the MongoDB Atlas page
- Click "Start Free" and sign up for the MongoDB account
- On the "Projects" page click on "New Project" give it a name and create
- Add Members. You’re already a member -> hit continue
- Build Cluster -> Select Free Tier
- Select Cloud Provider & Region and Create Cluster
- After the cluster was initialized click on "connect"
Choose a connection method -> Select Connect Your Application and select Node.js
Copy and save your connection string, which should look like the following:
mongodb+srv://<dbname>:<password>@cluster0-yvwjx.mongodb.net/<dbname>?retryWrites=true&w=majority
Keep that connection string saved somewhere handy. Later on, we will store the connection string as a new environment variable called MONGO_URI
.
link Koin and KMongo
In this section, we will continue developing with Koin, a dependency injection library.
Start by creating a new directory called di in the src directory. Inside the di directory, create a file named MainModule.kt
and add the following:
import org.koin.dsl.module
import org.litote.kmongo.KMongo
val mainModule = module(createdAtStart = true) {
factory { KMongo.createClient(System.getenv("MONGO_URI") ?: "") }
}
In the above code, we create a global KMongo client that will connect to a MongoDB database. We use the factory
function provided by Koin to provide a factory bean definition. A factory bean is a pattern to encapsulate object construction logic in a class. In this case it will be used to encode the construction of a KMongo client in a reusable way.
We are now ready to insert our MONGO_URI
connection string as an environment variable. At the root of the project, create a filed name .env
and insert the following:
MONGO_URI=mongodb+srv://<dbname>:<password>@cluster0-yvwjx.mongodb.net/<dbname>?retryWrites=true&w=majority
Be sure to replace the example with your actual connection string.
You may also need to configure the environment variable as JVM args in IntelliJ.
link Start Koin
To start Koin in the context of our application, add the following to src/Application.kt
:
fun Application.module(testing: Boolean = false) {
startKoin {
modules(mainModule)
}
install(GraphQL) {
// ...
}
}
Now that the Koin application context has been created, we can begin to create services for our project. Let's begin with making some updates to our RepositoryInterface
.
link Repository Interface Updates
One thing that always comes to mind when coding is the DRY principle: Don't Repeat Yourself. During this project, I wanted to make it a point to omit any duplicate code snippets.
To omit duplicate code snippets for our repositories, I added the logic to create, read, update and destroy models in the RepositoryInterface
.
Let's add the same CRUD logic and reduce the code we have to write later on.
Back in src/repository/RepositoryInterface
, make the following changes:
import com.example.models.Model
import com.mongodb.client.MongoCollection
import org.litote.kmongo.eq
import org.litote.kmongo.findOne
import org.litote.kmongo.updateOne
import java.lang.Exception
interface RepositoryInterface<T> {
var col: MongoCollection<T>
fun getById(id: String): T {
return try {
col.findOne(Model::id eq id)
?: throw Exception("No item with that ID exists")
} catch (t: Throwable) {
throw Exception("Cannot get item")
}
}
fun getAll(): List<T> {
return try {
val res = col.find()
res.asIterable().map { it }
} catch (t: Throwable) {
throw Exception("Cannot get all items")
}
}
fun delete(id: String): Boolean {
return try {
col.findOneAndDelete(Model::id eq id)
?: throw Exception("No item with that ID exists")
true
} catch (t: Throwable) {
throw Exception("Cannot delete item")
}
}
fun add(entry: T): T {
return try {
col.insertOne(entry)
entry
} catch (t: Throwable) {
throw Exception("Cannot add item")
}
}
fun update(entry: Model): T {
return try {
col.updateOne(
Model::id eq entry.id,
entry
)
col.findOne(Model::id eq entry.id)
?: throw Exception("No item with that ID exists")
} catch (t: Throwable) {
throw Exception("Cannot update item")
}
}
}
In the above code, we added a KMongo collection and interface that takes a generic type definition. A few key notes:
- The KMongo collection
col
is responsible for connecting to the database. - We use the base
Model
interface to abstract out any logic related to specific models. This makes the interface methods reusable across model classes.
You may begin to see the benefits of the base Model
interface. Using Kotlin type reflection, we can pass in a generic model <T>
. As long as a model inherits from the Model
interface, we can pass in the id
property and retrieve or modify data.
link Dessert Repo Updates
Back in src/repository/DessertRepository.kt
, the client will connect to a MongoDB database and get an existing collection. The rest of the CRUD logic is handled by the RepositoryInterface
.
import com.example.models.Dessert
import com.mongodb.client.MongoClient
import com.mongodb.client.MongoCollection
import org.litote.kmongo.*
import kotlin.Exception
class DessertRepository(client: MongoClient) : RepositoryInterface<Dessert> {
override lateinit var col: MongoCollection<Dessert>
init {
val database = client.getDatabase("test")
col = database.getCollection<Dessert>("Dessert")
}
}
The above code no longer uses a local in-memory database, so we can safely delete src/data/database.kt
.
link Dessert Service
The goal of each service is to define what operations to perform on our database. For desserts, we will define several functions and create an instance of our DessertRepository
.
Create a new directory called services and create a new file inside services called DessertService.kt
and add the following:
import com.example.models.Dessert
import com.example.models.DessertInput
import com.example.repository.DessertRepository
import com.mongodb.client.MongoClient
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.UUID
class DessertService: KoinComponent {
private val client: MongoClient by inject()
private val repo: DessertRepository = DessertRepository(client)
fun getDessert(id: String): Dessert {
return repo.getById(id)
}
fun createDessert(dessertInput: DessertInput, userId: String): Dessert {
val uid = UUID.randomUUID().toString()
val dessert = Dessert(
id = uid,
userId = userId,
name = dessertInput.name,
description = dessertInput.description,
imageUrl = dessertInput.imageUrl
)
return repo.add(dessert)
}
fun updateDessert(userId: String, dessertId: String, dessertInput: DessertInput): Dessert {
val dessert = repo.getById(dessertId)
if (dessert.userId == userId) {
val updates = Dessert(
id = dessertId,
userId = userId,
name = dessertInput.name,
description = dessertInput.description,
imageUrl = dessertInput.imageUrl
)
return repo.update(updates)
}
error("Cannot update dessert")
}
fun deleteDessert(userId: String, dessertId: String): Boolean {
val dessert = repo.getById(dessertId)
if (dessert.userId == userId) {
return repo.delete(dessertId)
}
error("Cannot delete dessert")
}
}
Dessert Schema Updates
The DessertSchema
now has a dependency on the DessertService
, which in turn relies on the DessertRepository
. Update src/graphql/DessertSchema.kt
as follows:
import com.apurebase.kgraphql.Context
import com.apurebase.kgraphql.schema.dsl.SchemaBuilder
import com.example.models.Dessert
import com.example.models.DessertInput
import com.example.services.DessertService
fun SchemaBuilder.dessertSchema(dessertService: DessertService) {
inputType<DessertInput> {
description = "The input of the dessert without the identifier"
}
type<Dessert> {
description = "Dessert object with attributes name, description and imageUrl"
}
query("dessert") {
resolver { dessertId: String ->
try {
dessertService.getDessert(dessertId)
} catch (e: Exception) {
null
}
}
}
mutation("createDessert") {
description = "Create a new dessert"
resolver { dessertInput: DessertInput, ctx: Context ->
try {
val userId = "abc"
dessertService.createDessert(dessertInput, userId)
} catch (e: Exception) {
null
}
}
}
mutation("updateDessert") {
description = "Updates a dessert"
resolver { dessertId: String, dessertInput: DessertInput, ctx: Context ->
try {
val userId = "abc"
dessertService.updateDessert(userId, dessertId, dessertInput)
} catch (e: Exception) {
null
}
}
}
mutation("deleteDessert") {
description = "Deletes a dessert"
resolver { dessertId: String, ctx: Context ->
try {
val userId = "abc"
dessertService.deleteDessert(userId, dessertId)
} catch(e: Exception) {
false
}
}
}
}
Application Updates
To wire up the updated DessertSchema.kt
, return to src/Application.kt
and insert the following:
import com.example.services.DessertService
// ...
startKoin {
modules(mainModule)
}
install(GraphQL) {
val dessertService = DessertService()
playground = true
schema {
dessertSchema(dessertService)
}
}
The backend now supports a new repository structure: Application -> Schema -> Service -> Repository
.
At this point, make sure the application is able to build and run successfully.
link Pagination
Let's take this opportunity to introduce a new feature: Pagination
.
Specifically, we are going to implement limit-offset pagination. This will reduce the initial load for the frontend, and increase the speed of subsequent responses.
In order to implement a new response, we will work our way backwards: Repository -> Service -> Schema
.
DessertPage and PagingInfo
Starting in src/models/Dessert.kt
, we will add two new models to handle paging desserts:
// 1
data class PagingInfo(var count: Int, var pages: Int, var next: Int?, var prev: Int?)
// 2
data class DessertsPage(val results: List<Dessert>, val info: PagingInfo)
Here's what the above code defines:
The PagingInfo data class provides the attributes for paged responses.
The DessertsPage data class is a wrapper for returning a list of desserts and paging info.
Dessert Repository
Now that we added the PagingInfo
and DessertsPage
classes, we can start to implement the paginated response.
Return to src/repository/DessertRepository.kt
, and insert the following:
fun getDessertsPage(page: Int, size: Int): DessertsPage {
try {
val skips = page * size
val res = col.find().skip(skips).limit(size)
?: throw Exception("No desserts exist")
val results = res.asIterable().map { it }
val totalDesserts = col.estimatedDocumentCount()
val totalPages = (totalDesserts / size) + 1
val next = if (results.isNotEmpty()) page + 1 else null
val prev = if (page > 0) page - 1 else null
val info = PagingInfo(totalDesserts.toInt(), totalPages.toInt(), next, prev)
return DessertsPage(results, info)
} catch (t: Throwable) {
throw Exception("Cannot get desserts page")
}
}
Source: mongodb.com
The above code handles skipping over database nodes and fetching the number of requested desserts. We can also return a PagingInfo
object with the following attributes: totalDesserts
, totalPages
, next
, prev
.
Dessert Service
In order to call the getDessertsPage
function, we will wire it up to our DessertService
.
Return to src/repository/DessertService.kt
, and insert the following:
fun getDessertsPage(page: Int, size: Int): DessertsPage {
return repo.getDessertsPage(page, size)
}
Nothing fancy here, just sending the necessary arguments along to the repository. Where exactly are these two arguments coming from?
Dessert Schema Updates
We will wire up the arguments for a paginated request in the DessertSchema
.
Return to src/graphql/DessertSchema.kt
and insert the following:
query("desserts") {
resolver { page: Int?, size: Int? ->
try {
dessertService.getDessertsPage(page ?: 0, size ?: 10)
} catch (e: Exception) {
null
}
}
}
Both page
and size
are optional arguments, so we can reference them or pass in default values.
Test Mutations
Fire up the project and visit the playground at localhost:8080/graphql.
Before testing paginated queries, we will need to create new dessert data.
Run the createDessert
mutation several times and create at least five new desserts.
Test Queries
Insert the following paginated query:
query GetDesserts($page: Int, $size: Int) {
desserts(page: $page, size: $size) {
results {
id
name
description
imageUrl
}
info {
count
pages
next
prev
}
}
}
In the query variables section, insert the following:
{"page": 0, "size": 10}
If you were able to fetch a paginated list of desserts, well done!
Today we successfully implemented new services and Koin components.
Up next, we will introduce a new reviews feature.