4 minutes read

Previously, we learned Koin basics, a lightweight dependency injection framework designed for Kotlin applications. We learned about handling dependencies and achieving loose coupling within the codebase.

In this topic, we will explore the integration of Koin with Ktor. We will see how to use dependency injection in building a backend, and the advantages of using a dependency injection framework for backend development.

Building application

To see how dependency injection will work in practice, let's create a simple order tracking application.

To begin with, we will need the user and order models. By implementing the IdClass interface, both User and Order data classes guarantee that they have an id property of type Int. This can be useful for creating generic functions that operate on objects that have an id property:

interface IdClass {
    val id: Int
}

data class User(
    override val id: Int,
    val email: String
) : IdClass

data class Order(
    override val id: Int,
    val user: User,
    val name: String
) : IdClass

We need a database to work with each model. To be simple, we will not use a database but emulate the operation of the database using the data variable:

data class Storage<T : IdClass>(
    val data: MutableList<T> = mutableListOf()
)

Storage is used in the Repository layer, which is needed to perform standard create, read, update and delete or CRUD operations (we will use only create and read):

interface Repository<T : IdClass> {
    val storage: Storage<T>

    fun save(entity: T) = storage.data.add(entity)

    fun findAll() = storage.data

    fun findById(id: Int) = storage.data.firstOrNull { it.id == id }
}

Since we have two models that we work with, we can make two implementations of this interface:

class UserRepository : Repository<User> {
    override val storage = Storage<User>()
}

class OrderRepository : Repository<Order> {
    override val storage = Storage<Order>()
}

Often, standard CRUD operations are not enough to implement the business logic of the application. Therefore, the business logic is implemented in the Service layer. Let's say users can register, add new orders, and receive a list of orders by user ID:

class Service {
    private var idCounter = 0
    private val userRepository = UserRepository()
    private val orderRepository = OrderRepository()

    fun signup(email: String): Int {
        val user = User(id = ++idCounter, email = email)
        userRepository.save(user)
        return idCounter
    }

    fun listOrders(): List<Order> {
        return orderRepository.findAll()
    }

    fun placeOrder(userId: Int, orderName: String): Int {
        val user = userRepository.findById(userId) ?: throw Exception("User not found!")
        val order = Order(id = ++idCounter, user = user, name = orderName)
        orderRepository.save(order)
        return idCounter
    }
}

The last layer we will need is a router to interact with the user:

fun Application.configureRouting() {
    val service = Service()
    routing {
        post("/signup") {
            val parameters = call.receiveParameters()
            val email = parameters["email"] ?: throw Exception("Email not passed!")
            val response = service.signup(email)
            call.respondText(response.toString())
        }

        route("/orders") {
            get {
                val response = service.listOrders()
                call.respondText(response.toString())
            }

            post {
                val parameters = call.receiveParameters()
                val userId = parameters["userId"]?.toIntOrNull() ?: throw Exception("UserId not passed!")
                val orderName = parameters["orderName"]          ?: throw Exception("OrderName not passed!")
                val response = service.placeOrder(userId, orderName)
                call.respondText(response.toString())
            }
        }
    }
}

In the given code, for example, the Service class has direct dependencies on the UserRepository and OrderRepository classes, which it creates and manages internally. That makes it difficult to modify or replace these dependencies without modifying the Service class, which violates the principle of separation of concerns.

By introducing Koin for dependency injection, we can decouple classes from their dependencies and make them more modular and reusable.

Installation

To build a Ktor application with Koin, you need to add the following artifacts to your build.gradle.kts file:

  1. koin-core: This is the core module of Koin that contains the core functionality of the framework. It includes the main components of the dependency injection system, such as modules, definitions, and scopes. This module is required for using Koin in any Kotlin project.

  2. koin-ktor: This is a module for integrating Koin with Ktor, which is a Kotlin-based web application framework. It provides an extension function to the Ktor Application class that allows for easy registration of Koin modules and injection of dependencies into Ktor application components. This dependency is optional, but koin-ktor makes it easier to integrate Koin into the Ktor application. We will consider this further.

  3. koin-logger-slf4j: This is a module for integrating Koin with SLF4J, which is a logging facade for various logging frameworks. It provides a way to configure Koin to use SLF4J as its logging backend. This module is optional, but it can be useful for logging in Koin-based applications.

The dependencies included in build script will look like this:

implementation("io.insert-koin:koin-core:$koin_version")
implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")

Decoupling components

Let's start by loosening the connection between Repository and Storage. We have two models and, accordingly, two Storage instances that we can inject (for User and Order). Let's define the module in this way:

val storageModule = module {
    single { Storage<User>() }
    single { Storage<Order>() }
}

We can inject dependencies (do not forget to implement the KoinComponent interface for convenience):

class UserRepository : KoinComponent, Repository<User> {
    override val storage: Storage<User> by inject()
}

class OrderRepository : KoinComponent, Repository<Order> {
    override val storage: Storage<Order> by inject()
}

However, such code will not work. Koin definitions don't take into account generic type argument. To solve this problem, the components must be named (using string or type qualifiers):

val storageModule = module {
    single(named("userStorage")) { Storage<User>() }
    single(named("orderStorage")) { Storage<Order>() }
}

class UserRepository : KoinComponent, Repository<User> {
    override val storage: Storage<User> by inject(named("userStorage"))
}

class OrderRepository : KoinComponent, Repository<Order> {
    override val storage: Storage<Order> by inject(named("orderStorage"))
}

Service still depends on two different Repository implementations. Let's inject a dependency on the interface, not the implementation. We also need named components:

val repositoryModule = module {
    single<Repository<User>>(named("userRepository")) { UserRepository() }
    single<Repository<Order>>(named("orderRepository")) { OrderRepository() }
}

class Service : KoinComponent {
    private val userRepository by inject<Repository<User>>(named("userRepository"))
    private val orderRepository by inject<Repository<Order>>(named("orderRepository"))
    ...
}

Configuring Koin for Ktor

One of the main features of the koin-ktor library is its support for Ktor's application lifecycle. Koin-ktor provides a module that can be used to define dependencies specific to the application's lifecycle, such as application-level configuration settings or database connections. These dependencies can be automatically initialized and disposed of as the application starts up and shuts down.

After defining the modules, we need to declare them in the install function so that Koin starts managing them. If we want to enable a Koin logger, we can pass it to the install function as well.

Here's an example of how to configure Koin in the Application class:

fun Application.configureKoin() {
    install(Koin) {
        slf4jLogger()
        modules(storageModule, repositoryModule)
    }
}

fun Application.main() {
    configureKoin()
    ...
}

We can also use the koin function:

fun Application.configureKoin() {
    koin {
        modules(configModule)
    }
}

In general, the koin function provided by koin-ktor is used to manage dependencies at the application level. This means that the koin function is used to register Koin with Ktor and define dependencies that are used across the entire application.

For example, application-level configuration settings or database connections are dependencies that are required by multiple components or features of an application. By using the koin function, these dependencies can be defined and managed at the application level, making it easier to ensure consistency and avoid redundancy in the application's dependency graph.

Injecting in router

We still have the last component to manage as a dependency, the Service itself. But, we will inject it not into another class. Fortunately, Koin inject() and get() functions are available from Application, Route, and Routing classes.

First, we will also create a module in which we declare the Service component:

val serviceComponent = module {
    single { Service() }
}

fun Application.configureKoin() {
    install(Koin) {
        slf4jLogger()
        modules(..., serviceComponent)
    }
}

So we can inject the service into the Application extension function:

fun Application.configureRouting() {
    val service by inject<Service>()

    routing { ... }
}

Or directly to the router:

fun Application.configureRouting() {
    routing {
        val service by inject<Service>()
        ...
    }
}

Ktor Koin events

Ktor Koin events are based on Ktor's application lifecycle events and allow developers to manage dependencies at specific points in the application's lifecycle. There are 3 Ktor Koin events:

  1. KoinApplicationStarted: This event is triggered when Koin has finished starting up and is ready to use.

  2. KoinApplicationStopPreparing: This event is triggered when Koin is preparing to shut down.

  3. KoinApplicationStopped: This event is triggered when Koin has finished shutting down.

To use ktor-koin events, developers can define a module using the module function provided by the koin-ktor and use the on function to listen for specific Ktor events. Within the on function, dependencies can be defined using the single and factory functions provided by Koin. For example:

val dbModule = module {
    single { DatabaseConnection() }

    environment.monitor.subscribe(KoinApplicationStarted) {
        val db = get<DatabaseConnection>()
        db.connect()
    }

    environment.monitor.subscribe(KoinApplicationStopPreparing) {
        val db = get<DatabaseConnection>()
        db.disconnect()
    }
}

Conclusion

We have explored the integration of Koin with Ktor and how dependency injection can be used in building a backend. Using Koin, we can decouple classes from their dependencies and make them more modular and reusable. This allows for greater flexibility and easier maintenance of the codebase, as changes can be made to individual components without affecting the entire system.

Overall, the combination of Koin and Ktor offers a toolset for building scalable, maintainable, and efficient backends. With Koin, developers can create highly modular and testable code that is easy to maintain and modify.

5 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo