What is Dependency Injection and Why Do You Need It in Kotlin?
Dependency Injection (DI) is one of the key design principles in modern development, especially in Kotlin. If you are just starting your programming journey, this concept may seem complex. In reality, it's simple: Dependency Injection is a way to pass an object its dependencies from the outside, rather than creating them inside the object itself.
Let's break it down with a real-world example. Imagine you are baking a pie. Instead of grinding flour, collecting eggs, and churning butter yourself every time (creating dependencies internally), you simply go to the store and buy ready-made ingredients (receiving dependencies from outside). DI works on the same principle: you "order" ready-made components instead of creating them manually.
In Kotlin, DI is especially important because the language is actively used for creating Android applications, server-side solutions with Ktor, and microservices. Without DI, code becomes tightly coupled, difficult to test, and hard to maintain. With DI, you get:
- Flexibility — easily replace one implementation with another
- Testability — you can substitute mock objects
- Clean code — classes don't know how their dependencies are created
The Problem Without Dependency Injection
Let's consider a typical situation. We have a UserService class that works with a database. Without DI, it looks like this:
class UserService { private val database = Database() // Tight coupling
fun getUser(id: Int): User? { return database.query("SELECT * FROM users WHERE id = $id") }}What are the problems here? First, UserService creates the Database instance itself. If we want to replace the real database with a test one (e.g., in-memory), we have to change the code inside the class. Second, unit testing becomes impossible — we cannot substitute a fake database. Third, the class violates the Single Responsibility Principle (SRP): it not only handles users but also manages the creation of the database.
Imagine having dozens of such dependencies — the code turns into "spaghetti" where everything is connected to everything else. Dependency Injection exists precisely to solve this problem.
Manual Implementation of Dependency Injection
The simplest way to inject dependencies is to pass them through the constructor. This is called constructor injection. Let's rewrite our example:
class UserService(private val database: Database) { fun getUser(id: Int): User? { return database.query("SELECT * FROM users WHERE id = $id") }}
// Usage:val database = Database()val userService = UserService(database)Now UserService doesn't know how Database is created. It simply receives a ready-made instance. We can easily substitute the database:
class FakeDatabase : Database() { override fun query(sql: String): User? { return User(1, "Test User") }}
// In tests:val fakeDb = FakeDatabase()val userService = UserService(fakeDb)This approach is called manual DI. For small projects, it is perfectly sufficient. You simply create all dependencies in one place (e.g., in main() or in a special container class) and pass them where they are needed.
However, manual DI has a drawback: when a project grows, the number of dependencies becomes huge. You will have to manually track the order of object creation and pass them through multiple levels. This is where DI frameworks come to the rescue.
Dependency Injection with Koin
Koin is a popular DI framework for Kotlin that does not require code generation