Dependency Injection Basics
This guide introduces the fundamental concepts of Dependency Injection (DI) and how Koin implements these patterns in Kotlin applications.
What is Dependency Injection?
Dependency Injection is a design pattern where objects receive their dependencies from external sources rather than creating them internally. This promotes loose coupling, better testability, and cleaner code architecture.
A Simple Example: Car and Engine
Consider a car that needs an engine. Without dependency injection:
class Engine {
fun start() {
println("Engine starting...")
}
}
class Car {
private val engine = Engine() // Car creates its own engine
fun drive() {
engine.start()
println("Car is driving")
}
}
Problems with this approach:
Caris tightly coupled to a specificEngineimplementation- Difficult to test
Carindependently - Hard to swap engine types (electric, diesel, etc.)
Carcontrols the lifecycle ofEngine
With Dependency Injection
class Car(private val engine: Engine) { // Engine is injected
fun drive() {
engine.start()
println("Car is driving")
}
}
// Now we can easily provide different engines
val gasolineCar = Car(GasEngine())
val electricCar = Car(ElectricEngine())
Benefits:
Cardoesn't know howEngineis created- Easy to test with mock engines
- Flexible - can swap implementations
- Clear dependencies visible in constructor
Three Ways to Obtain Dependencies
There are three common ways to provide dependencies to a class:
1. Constructor Injection (Recommended)
Dependencies are passed through the constructor:
class UserRepository(
private val database: Database,
private val apiClient: ApiClient
) {
fun getUser(id: String): User {
return database.query(id) ?: apiClient.fetchUser(id)
}
}
Advantages:
- Dependencies are explicit and required
- Immutable (using
val) - Easy to test
- Clear dependency graph
With Koin:
val appModule = module {
single { Database() }
single { ApiClient() }
single { UserRepository(get(), get()) }
}
Constructor injection is the preferred approach in Koin. It makes your code testable without requiring Koin in unit tests.
2. Field Injection
Dependencies are injected into class properties:
class UserActivity : AppCompatActivity() {
// Lazy injection - instance created when first accessed
private val viewModel: UserViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.loadUser() // ViewModel instance created here
}
}
When to use:
- Android framework classes (Activity, Fragment, Service) that you don't control the construction of
- When constructor injection isn't possible
With Koin:
// Lazy injection
val presenter: Presenter by inject()
// Eager injection
val presenter: Presenter = get()
For more details on Android-specific injection, see Injecting in Android.
3. Method Injection
Dependencies are passed through methods (less common):
class ReportGenerator {
fun generateReport(data: DataSource) {
// Use data to generate report
}
}
When to use:
- Optional dependencies
- Dependencies that change during object lifetime
- Callback patterns
Manual vs Automated Dependency Injection
The Problem with Manual DI
As applications grow, managing dependencies manually becomes complex:
// In your Activity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Manually creating the entire dependency graph
val database = Database()
val apiClient = ApiClient()
val userRepository = UserRepository(database, apiClient)
val authRepository = AuthRepository(database, apiClient)
val userService = UserService(userRepository, authRepository)
val viewModel = UserViewModel(userService)
// Finally can use viewModel...
}
}
Problems:
- Repetitive code across Activities/Fragments
- Easy to make mistakes in dependency order
- Hard to maintain as the app grows
- Difficult to manage lifecycles (singletons, scoped objects)
- No centralized configuration
The Container Pattern (Manual Approach)
Developers often create a container to centralize object creation:
object AppContainer {
private val database by lazy { Database() }
private val apiClient by lazy { ApiClient() }
val userRepository by lazy { UserRepository(database, apiClient) }
val authRepository by lazy { AuthRepository(database, apiClient) }
fun createUserViewModel() = UserViewModel(
UserService(userRepository, authRepository)
)
}
// Usage
class MainActivity : AppCompatActivity() {
private val viewModel = AppContainer.createUserViewModel()
}
Still has issues:
- Manual wiring of dependencies
- No automatic lifecycle management
- Global state (singleton container)
- Still repetitive for complex graphs
How Koin Solves This
Koin provides automated dependency resolution with a clean DSL:
// Define dependencies once
val appModule = module {
single { Database() }
single { ApiClient() }
single { UserRepository(get(), get()) }
single { AuthRepository(get(), get()) }
single { UserService(get(), get()) }
viewModel { UserViewModel(get()) }
}
// Start Koin once
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
modules(appModule)
}
}
}
// Use anywhere - Koin handles the entire dependency graph
class MainActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModel()
// That's it! Koin creates UserViewModel and all its dependencies
}
Koin advantages:
- Declarative dependency configuration
- Automatic dependency resolution
- Lifecycle management (singleton, factory, scoped)
- Type-safe injection
- Easy testing and module replacement
Koin's Autowire DSL (Recommended)
For classes using constructor injection, Koin provides an even cleaner DSL that automatically wires dependencies:
Classic DSL vs Autowire DSL
Classic DSL:
val appModule = module {
single { Database() }
single { ApiClient() }
single { UserRepository(get(), get()) } // Manually call get() for each dependency
single { AuthRepository(get(), get()) }
single { UserService(get(), get()) }
viewModel { UserViewModel(get()) }
}
Autowire DSL (Better):
val appModule = module {
singleOf(::Database)
singleOf(::ApiClient)
singleOf(::UserRepository) // Koin automatically resolves constructor parameters!
singleOf(::AuthRepository)
singleOf(::UserService)
viewModelOf(::UserViewModel)
}
Available Autowire Functions
| Autowire Function | Classic Equivalent | Description |
|---|---|---|
singleOf(::MyClass) | single { MyClass(get(), ...) } | Singleton with autowiring |
factoryOf(::MyClass) | factory { MyClass(get(), ...) } | New instance each time with autowiring |
scopedOf(::MyClass) | scoped { MyClass(get(), ...) } | Scoped instance with autowiring |
viewModelOf(::MyViewModel) | viewModel { MyViewModel(get()) } | ViewModel with autowiring |
Complete Example
// Your classes with constructor injection
class Database
class ApiClient
class UserRepository(val database: Database, val apiClient: ApiClient)
class AuthRepository(val database: Database, val apiClient: ApiClient)
class UserService(val userRepo: UserRepository, val authRepo: AuthRepository)
class UserViewModel(val userService: UserService) : ViewModel()
// Clean module definition with autowiring
val appModule = module {
singleOf(::Database)
singleOf(::ApiClient)
singleOf(::UserRepository)
singleOf(::AuthRepository)
singleOf(::UserService)
viewModelOf(::UserViewModel)
}
// Koin automatically resolves the entire dependency graph!
Compatibility with Parameters
The autowire DSL fully supports parametersOf() for runtime parameters:
class UserPresenter(
val userId: String, // Runtime parameter
val repository: UserRepository // Injected dependency
)
val appModule = module {
factoryOf(::UserPresenter) // Autowire handles both injected deps and parameters!
}
// Usage - parameters are automatically matched to constructor
class UserActivity : AppCompatActivity() {
val presenter: UserPresenter by inject { parametersOf("user123") }
// or
val presenter: UserPresenter = get { parametersOf("user123") }
}
The autowire DSL looks at the resolution parameters stack and propagates any parameters passed with parametersOf(), automatically matching them to the constructor parameters.
When to Use Autowire DSL
✅ Use autowire DSL when:
- Your class uses constructor injection
- You retrieve dependencies with
get()orby inject() - You want cleaner, more concise module definitions
- You need to pass runtime parameters with
parametersOf()✓
❌ Use classic DSL when:
- You need
getOrNull()for optional dependencies - You need custom initialization logic
- You need to bind interfaces explicitly (though you can combine with
bind)
Limitations
The autowire DSL only works with:
- ✅
get()- eager retrieval - ✅
by inject()- lazy delegation - ✅
parametersOf()- runtime parameters - ❌
getOrNull()- NOT supported (use classic DSL instead)
Mixing Classic and Autowire DSL
You can mix both styles in the same module:
val appModule = module {
// Autowire for simple cases
singleOf(::Database)
factoryOf(::ApiClient)
// Autowire with interface binding
singleOf(::UserRepositoryImpl) bind UserRepository::class
// Classic DSL when you need custom logic
single {
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
// Classic DSL for optional dependencies
factory {
MyClass(
required = get(),
optional = getOrNull() // Can't use autowire here
)
}
}
Best Practice: Prefer the autowire DSL (singleOf, factoryOf, viewModelOf, scopedOf) for classes with constructor injection. It's more concise, reduces boilerplate, and fully supports parameters. Use the classic DSL only when you need getOrNull() or custom initialization logic.
Service Locator vs Dependency Injection
It's important to understand the difference between these patterns, as Koin supports both.
Service Locator Pattern
A centralized registry where you actively request dependencies:
class UserService : KoinComponent {
// Actively requesting dependencies
private val repository: UserRepository by inject()
private val logger: Logger by inject()
fun loadUser(id: String) {
logger.log("Loading user $id")
return repository.findUser(id)
}
}
Characteristics:
- Component "pulls" dependencies from container
- Dependencies not visible in constructor
- Uses
get()orinject()to retrieve instances
Dependency Injection Pattern
Dependencies are provided from outside:
class UserService(
private val repository: UserRepository,
private val logger: Logger
) {
fun loadUser(id: String) {
logger.log("Loading user $id")
return repository.findUser(id)
}
}
Characteristics:
- Dependencies "pushed" into component
- Dependencies explicit in constructor
- Easier to test (no framework needed)
When Koin Acts as Service Locator
When you use get() or by inject():
class MyActivity : AppCompatActivity() {
private val presenter: Presenter by inject() // Service Locator
private val service: MyService = get() // Service Locator
}
When Koin Acts as DI
When you use constructor injection:
// Definition uses constructor injection
val appModule = module {
single { UserRepository(get(), get()) } // DI - Koin injects dependencies
}
class UserRepository(
private val database: Database, // Dependencies injected by Koin
private val apiClient: ApiClient
)
Comparison Table
| Aspect | Service Locator | Dependency Injection |
|---|---|---|
| Dependency visibility | Hidden (inside class) | Explicit (constructor/properties) |
| Testing | Requires framework or mocking | Easy - pass test doubles |
| Coupling | Tighter (depends on container) | Looser (depends on interfaces) |
| Usage in Koin | get(), by inject() | Constructor parameters with get() in module |
| Best for | Android framework classes | Business logic, repositories, services |
Best Practices with Koin
- Prefer Constructor Injection for business logic classes:
// Good - testable without Koin
class UserViewModel(
private val userService: UserService
) : ViewModel() {
// ...
}
val appModule = module {
viewModel { UserViewModel(get()) }
}
- Use Service Locator only when necessary (Android framework classes):
// Acceptable - Activity construction controlled by Android
class UserActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModel()
}
- Avoid
KoinComponentin business logic:
// Bad - hard to test, hidden dependencies
class UserService : KoinComponent {
private val repository: UserRepository = get()
}
// Good - explicit dependencies, easy to test
class UserService(
private val repository: UserRepository
)
Koin's Philosophy: While Koin supports both patterns for flexibility (especially important for Android), it strongly encourages constructor injection for better testability and clearer code.
Benefits of Dependency Injection
1. Code Reusability and Decoupling
Without DI, components are tightly coupled:
class EmailService {
fun sendEmail(to: String, message: String) {
// Send via Gmail API
}
}
class UserRegistration {
private val emailService = EmailService() // Tight coupling
fun register(user: User) {
emailService.sendEmail(user.email, "Welcome!")
}
}
With DI, components are loosely coupled:
interface EmailService {
fun sendEmail(to: String, message: String)
}
class GmailService : EmailService { /* ... */ }
class SendGridService : EmailService { /* ... */ }
class UserRegistration(
private val emailService: EmailService // Depends on interface
) {
fun register(user: User) {
emailService.sendEmail(user.email, "Welcome!")
}
}
// Easy to swap implementations
val appModule = module {
single<EmailService> { GmailService() } // or SendGridService()
single { UserRegistration(get()) }
}
2. Easier Refactoring
When you need to add a new dependency:
// Before
class UserService(
private val repository: UserRepository
)
// After - just add parameter and update module
class UserService(
private val repository: UserRepository,
private val analytics: Analytics // New dependency
)
// Update module
val appModule = module {
single { UserService(get(), get()) } // Koin resolves both
}
3. Simplified Testing
Without DI:
class UserService {
private val repository = UserRepository() // Can't mock!
fun getUser(id: String): User {
return repository.findUser(id)
}
}
// Hard to test - can't replace repository
@Test
fun testGetUser() {
val service = UserService() // Always uses real repository
// Can't test in isolation
}
With DI:
class UserService(
private val repository: UserRepository
) {
fun getUser(id: String): User {
return repository.findUser(id)
}
}
// Easy to test - inject test double
@Test
fun testGetUser() {
val mockRepository = mockk<UserRepository>()
val service = UserService(mockRepository) // Full control
every { mockRepository.findUser("123") } returns testUser
val result = service.getUser("123")
assertEquals(testUser, result)
}
4. Koin-Specific Advantages
Module Verification
Koin can verify your dependency graph before runtime:
@Test
fun verifyKoinConfiguration() {
koinApplication {
modules(appModule, networkModule, dataModule)
}.checkModules() // Fails if dependencies can't be resolved
}
Lazy Module Loading (Koin 4.2.0+)
Load modules only when needed:
startKoin {
modules(coreModule)
lazyModules(
featureAModule,
featureBModule
)
}
// Modules loaded on first access
val featureA: FeatureA = get() // featureAModule loaded now
Kotlin DSL
Intuitive, type-safe configuration:
val appModule = module {
// Single instance
single { Database() }
// New instance each time
factory { Presenter() }
// Scoped to Activity lifecycle
scope<MainActivity> {
scoped { ActivityPresenter() }
}
// ViewModel with Android lifecycle
viewModel { UserViewModel(get()) }
}
Less Boilerplate
Koin:
val appModule = module {
single { UserRepository(get()) }
}
Compared to annotation-based frameworks:
@Module
class AppModule {
@Provides
@Singleton
fun provideUserRepository(database: Database): UserRepository {
return UserRepository(database)
}
}
Runtime Flexibility
Change configurations at runtime:
// Development
startKoin {
modules(module {
single<ApiClient> { MockApiClient() }
})
}
// Production
startKoin {
modules(module {
single<ApiClient> { RealApiClient() }
})
}
Summary
Dependency Injection is a powerful pattern that:
- Decouples components from their dependencies
- Improves testability by allowing dependency replacement
- Simplifies maintenance with centralized configuration
- Scales better than manual dependency management
Koin makes DI in Kotlin simple by:
- Providing a clean Kotlin DSL for configuration
- Supporting both constructor injection (recommended) and field injection (when needed)
- Offering module verification to catch errors early
- Enabling lazy loading for better performance
- Requiring zero annotation processing - pure Kotlin
Next Steps
Now that you understand DI fundamentals, explore how to use Koin in your applications:
- Starting Koin on Android - Set up Koin in your Android app
- Injecting in Android - Use
inject()andget()in Android components - Koin Modules - Organize your dependencies
- Definitions - Understand
single,factory, and scopes