Introduction: What is Dependency Injection and Why Do You Need It?
\nImagine you are building a house. You need bricks, cement, and tools. Instead of running to the warehouse for them every time, you simply order the delivery of all materials to the construction site. Dependency Injection (DI) is the same principle, but for your code.
\nDependency Injection is a design pattern where an object receives its dependencies from the outside, rather than creating them itself. This makes the code more flexible, testable, and easier to maintain. In this article, we will break down DI using Kotlin — a modern programming language that is great for learning this pattern.
\n\nThe Problem: Why Does Code Become Fragile Without DI?
\nLet's look at a typical example. Suppose we have a class UserService that sends email notifications. Without DI, it looks like this:
class EmailService {\n fun sendEmail(to: String, message: String) {\n println("Sending email to $to: $message")\n }\n}\n\nclass UserService {\n private val emailService = EmailService() // Tight coupling!\n\n fun notifyUser(email: String) {\n emailService.sendEmail(email, "Hello, user!")\n }\n}\nWhat's the problem? UserService is tightly coupled to a specific implementation of EmailService. If you want to:
\n- \n
- Replace EmailService with SmsService; \n
- Add logging; \n
- Write unit tests with a mock object, \n
— you will have to change the code of UserService. This violates the Open/Closed principle and complicates testing.
\n\nThe Solution: Manual Dependency Injection in Kotlin
\nThe simplest way to implement DI is to pass the dependency through the constructor. In Kotlin, this is done very concisely:
\n// 1. Create an interface (abstraction)\ninterface NotificationService {\n fun send(to: String, message: String)\n}\n\n// 2. Implement concrete services\nclass EmailService : NotificationService {\n override fun send(to: String, message: String) {\n println("Email to $to: $message")\n }\n}\n\nclass SmsService : NotificationService {\n override fun send(to: String, message: String) {\n println("SMS to $to: $message")\n }\n}\n\n// 3. Inject dependency via constructor\nclass UserService(private val notificationService: NotificationService) {\n fun notifyUser(email: String) {\n notificationService.send(email, "Hello!")\n }\n}\n\n// 4. Usage\nfun main() {\n val emailService = EmailService()\n val userService = UserService(emailService) // Injection!\n userService.notifyUser("user@example.com")\n}\nAdvantages of this approach:
\n- \n
- Flexibility: you can easily replace
EmailServicewithSmsService; \n - Testability: in tests, you can pass a mock object: \n
class MockNotificationService : NotificationService {\n var lastMessage: String? = null\n override fun send(to: String, message: String) {\n lastMessage = message\n }\n}\n\nfun test() {\n val mock = MockNotificationService()\n val userService = UserService(mock)\n userService.notifyUser("test@test.com")\n assert(mock.lastMessage == "Hello!")\n}\n\nDI Frameworks: Koin and Dagger for Kotlin
\nWith manual DI, if the project is large, you write a lot of boilerplate code. This is where DI frameworks come to the rescue. In the Kotlin world, two are popular: Koin (simple) and Dagger/Hilt (powerful).
\n\nKoin: Simplicity and Speed
\nKoin is a lightweight DI framework written in Kotlin. It does not require code generation and is