Introduction to Dependency Injection in Kotlin
Modern Kotlin development requires proper management of dependencies between classes. Dependency Injection (DI) is a design pattern that allows passing an object's dependencies from the outside, rather than creating them internally. This reduces code coupling, simplifies testing, and makes the application more flexible.
In this article, we will explore what dependency injection is, why it is needed, and examine three approaches to implementing it in Kotlin: manual injection, using the Hilt library, and the lightweight Koin framework. The material will be useful for both beginners and experienced developers looking to organize their project architecture.
Basics of Dependency Injection
The Problem Without DI
Imagine a UserService class that directly creates an instance of UserRepository:
class UserService { private val repository = UserRepository() // tight coupling
fun getUser(id: Int): User { return repository.findById(id) }}Such code is difficult to test: you cannot replace UserRepository with a mock object. Additionally, if the constructor of UserRepository changes, you will have to modify all places where it is created.
Solution: Constructor Injection
The simplest way to apply DI is to pass the dependency through the constructor:
class UserService(private val repository: UserRepository) { fun getUser(id: Int): User { return repository.findById(id) }}Now UserService does not know how UserRepository is created. You can pass any implementation — a real database, a fake layer, or a mock for testing.
Inversion of Control (IoC)
DI is closely related to the Inversion of Control (IoC) principle. Instead of the class controlling the creation of its dependencies, control is handed over to an external container or factory. This makes the system loosely coupled and easily extensible.
Manual Dependency Injection (Manual DI)
For small projects or educational purposes, you can manage without frameworks. Let's create a simple dependency container manually.
Example: Dependency Container
// Define the repository interfaceinterface UserRepository { fun findById(id: Int): User}
// Implementationclass UserRepositoryImpl : UserRepository { override fun findById(id: Int): User { // logic for retrieving user from the database return User(id, "User$id") }}
// Container that assembles all dependenciesclass AppContainer { val userRepository: UserRepository = UserRepositoryImpl() val userService: UserService by lazy { UserService(userRepository) }}
// Usagefun main() { val container = AppContainer() val service = container.userService println(service.getUser(1))}Pros: full control, no external dependencies, easy to understand.
Cons: as the project grows, the container expands, making it difficult to manage object lifecycles and scopes.
Dependency Injection with Hilt
Hilt is Google's official library for DI in Android applications using Kotlin. It is built on top of Dagger but is significantly easier to configure.
Setting up Hilt
Add to build.gradle.kts (project level):
plugins { id("com.google.dagger.hilt.android") version "2.48" apply false}In the app module's build.gradle.kts:
plugins { id("com.google.dagger.hilt.android")}
dependencies { implementation("com.google.dagger:hilt-android:2.48") kapt("com.google.dagger:hilt-compiler:2.48")