Skip to main content
Version: 4.2

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:

  • Car is tightly coupled to a specific Engine implementation
  • Difficult to test Car independently
  • Hard to swap engine types (electric, diesel, etc.)
  • Car controls the lifecycle of Engine

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:

  • Car doesn't know how Engine is 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:

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()) }
}
info

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()
note

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

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 FunctionClassic EquivalentDescription
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") }
}
info

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() or by 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
)
}
}
info

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() or inject() 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

AspectService LocatorDependency Injection
Dependency visibilityHidden (inside class)Explicit (constructor/properties)
TestingRequires framework or mockingEasy - pass test doubles
CouplingTighter (depends on container)Looser (depends on interfaces)
Usage in Koinget(), by inject()Constructor parameters with get() in module
Best forAndroid framework classesBusiness logic, repositories, services

Best Practices with Koin

  1. Prefer Constructor Injection for business logic classes:
// Good - testable without Koin
class UserViewModel(
private val userService: UserService
) : ViewModel() {
// ...
}

val appModule = module {
viewModel { UserViewModel(get()) }
}
  1. Use Service Locator only when necessary (Android framework classes):
// Acceptable - Activity construction controlled by Android
class UserActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModel()
}
  1. Avoid KoinComponent in 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
)
info

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: