Skip to main content
Version: 4.2

What is Dependency Injection?

Dependency Injection (DI) 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.

What is a Dependency?

A dependency is any object that another object needs to function. For example, a Car depends on an Engine to drive.

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 Provide Dependencies

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>() // Koin auto-wires dependencies
}
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) where you don't control construction
  • When constructor injection isn't possible

With Koin:

// Lazy injection
val presenter: Presenter by inject()

// Eager injection
val presenter: Presenter = get()

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:

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 your choice of DSL or Annotations:

// Define dependencies once
val appModule = module {
single<Database>()
single<ApiClient>()
single<UserRepository>()
single<AuthRepository>()
single<UserService>()
viewModel<UserViewModel>()
}

// 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

Automated DI Solutions

There are different approaches to automated dependency injection:

ApproachExamplesHow it works
Reflection-based(older frameworks)Uses reflection at runtime
Code generationDagger, HiltGenerates code at compile time (annotation processing)
Compiler pluginsKoin Compiler PluginNative compiler integration for DSL & Annotations
DSL-basedKoin (classic)Runtime DSL configuration

Koin's approach - DSL & Annotations, both equally powerful:

  • DSL style: Clean Kotlin DSL configuration (single<MyService>(), viewModel<MyVM>())
  • Annotation style: Familiar annotations (@Singleton, @KoinViewModel)
  • Both powered by the same Compiler Plugin for compile-time safety
  • No reflection, lightweight
  • Choose the style that fits your team

Service Locator vs Dependency Injection

It's important to understand the difference:

Service Locator Pattern

Components actively request dependencies from a registry:

class UserService : KoinComponent {
private val repository: UserRepository by inject() // "Pulling" dependency
}

Dependency Injection Pattern

Dependencies are provided from outside:

class UserService(
private val repository: UserRepository // "Pushed" into component
)

Comparison

AspectService LocatorDependency Injection
Dependency visibilityHidden inside classExplicit in constructor
TestingRequires frameworkEasy - pass test doubles
CouplingDepends on containerDepends on interfaces
Usage in Koinget(), by inject()Constructor with Koin module
Best forAndroid framework classesBusiness logic, services

Best Practices with Koin

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

val appModule = module {
viewModel<UserViewModel>() // Koin resolves dependencies
}
  1. Use Service Locator only when necessary:
// 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
class UserService : KoinComponent {
private val repository: UserRepository = get()
}

// Good - explicit dependencies
class UserService(private val repository: UserRepository)

Benefits of Dependency Injection

1. Testability

Without DI, testing is difficult:

class UserService {
private val repository = UserRepository() // Can't mock!
}

With DI, testing is straightforward:

class UserService(private val repository: UserRepository)

@Test
fun testGetUser() {
val mockRepository = mockk<UserRepository>()
val service = UserService(mockRepository) // Full control

every { mockRepository.findUser("123") } returns testUser
assertEquals(testUser, service.getUser("123"))
}

2. Flexibility

Easily swap implementations:

val appModule = module {
single<EmailService> { GmailService() } // Production
}

val testModule = module {
single<EmailService> { MockEmailService() } // Testing
}

3. Code Organization

Centralized dependency configuration:

val dataModule = module {
single<Database>()
single<ApiClient>()
}

val domainModule = module {
single<UserRepository>()
single<AuthRepository>()
}

val presentationModule = module {
viewModel<UserViewModel>()
}

startKoin {
modules(dataModule, domainModule, presentationModule)
}

4. Lifecycle Management

Koin handles object lifecycles:

val appModule = module {
single<Database>() // One instance for entire app
factory<Presenter>() // New instance each time
scoped<SessionData>() // Instance per scope
}

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:

  • Offering two equally powerful styles: DSL or Annotations - your choice
  • Supporting both constructor injection (recommended) and field injection (when needed)
  • Providing compile-time safety with the Compiler Plugin
  • Requiring zero reflection - pure Kotlin

Next Steps