Dependency Injection with Koin on Android

AndroidKotlinDependency InjectionArchitecture
Share on LinkedIn

Koin is what you reach for when Dagger's annotation processing feels like overkill — or when you need dependency injection that works identically on Android, iOS, and backend KMP modules. It's pure Kotlin DSL: define modules, declare how to construct things, resolve by type at runtime. No @Inject, no @Component, no kapt/ksp code generation. The trade-off is real: Koin catches missing dependencies at runtime, not compile time. I've used Koin on KMP projects where Hilt can't go, and Hilt on large Android apps where compile-time graphs save hours of debugging. Pick based on your constraints, not ideology.

Setup

// Application.onCreate()
startKoin {
    androidContext(this@MyApp)
    modules(appModule, networkModule, databaseModule)
}

Dependencies:

dependencies {
    implementation("io.insert-koin:koin-android:3.5.6")
    implementation("io.insert-koin:koin-androidx-compose:3.5.6")
}

Defining modules

val networkModule = module {
    single { HttpClientFactory.create() }
    single { ApiService(get()) }
}

val databaseModule = module {
    single { AppDatabase.build(androidContext()) }
    single { get<AppDatabase>().userDao() }
}

val appModule = module {
    single<UserRepository> { UserRepositoryImpl(get(), get()) }
    viewModel { UserViewModel(get()) }
    viewModel { params -> DetailViewModel(id = params.get(), repo = get()) }
}
Declaration Scope Created
single { } Application Once
factory { } None Every injection
viewModel { } ViewModelStore Per ViewModel instance

Injection in Activities and Fragments

class UserActivity : AppCompatActivity() {
    private val viewModel: UserViewModel by viewModel()
    private val analytics: Analytics by inject()
}

Injection in Compose

@Composable
fun UserScreen(viewModel: UserViewModel = koinViewModel()) {
    val users by viewModel.users.collectAsStateWithLifecycle()
    // ...
}

For parameterized ViewModels:

@Composable
fun DetailScreen(itemId: String, vm: DetailViewModel = koinViewModel { parametersOf(itemId) })

Scopes

Koin supports custom scopes for lifecycle-bound dependencies:

val activityModule = module {
    scope<MainActivity> {
        scoped { SessionManager() }
        viewModel { MainViewModel(get()) }
    }
}

class MainActivity : AppCompatActivity() {
    private val scope = activityScope<MainActivity>()
    private val session: SessionManager by scope.inject()
}

For most apps, single + viewModel covers 90% of cases. Custom scopes are for session-bound or activity-bound objects that shouldn't live as singletons.

KMP shared modules

Koin's killer feature for multiplatform:

// sharedModule — commonMain
val sharedModule = module {
    single { UserRepository(get()) }
    single<UserApi> { UserApiImpl(get()) }
}

// androidMain — adds platform deps
val androidModule = module {
    single { DatabaseDriverFactory(androidContext()) }
}

// iosMain
val iosModule = module {
    single { DatabaseDriverFactory() }
}

Same module definitions, platform-specific bindings. This is why Koin dominates KMP DI while Hilt is Android-only.

Testing

class UserViewModelTest : KoinTest {
    @get:Rule
    val koinTestRule = KoinTestRule.create {
        modules(testModule)
    }

    private val testModule = module {
        single<UserRepository> { FakeUserRepository() }
        viewModel { UserViewModel(get()) }
    }

    @Test
    fun loadsUsers() = runTest {
        val vm: UserViewModel by inject()
        vm.loadUsers()
        assertEquals(2, vm.users.value.size)
    }
}

Override modules in tests without touching production code.

Koin vs Hilt decision matrix

Factor Koin Hilt
Setup time Minutes Hours (first time)
Compile time Zero overhead kapt/ksp processing
Error detection Runtime Compile time
KMP support Native Android only
Android integration Good Deep (WorkManager, etc.)
Learning curve Low (Kotlin DSL) High (Dagger concepts)
Ecosystem Smaller Google-backed

For large Android-only apps with 20+ modules, Hilt's compile-time safety and multibindings justify the overhead. For KMP, prototypes, or teams allergic to annotation processors, Koin is the pragmatic choice.

Common pitfalls

Missing dependency at runtime. Koin throws InstanceCreationException in production. Mitigate with startup validation in debug builds that resolves all declared types.

Over-using single for stateful objects. Not everything should be a singleton. Use factory for objects with per-use state.

Module organization sprawl. One module per layer (network, database, feature) keeps things navigable. A single 500-line module doesn't scale.

Koin Compose integration

Inject ViewModels and dependencies directly in composables:

@Composable
fun OrderScreen(viewModel: OrderViewModel = koinViewModel()) {
    val orders by viewModel.orders.collectAsStateWithLifecycle()
    OrderList(orders)
}

// Koin module
val featureModule = module {
    viewModel { OrderViewModel(get(), get()) }
    single { OrderRepository(get()) }
}

// Application/onCreate or commonMain
startKoin {
    modules(appModule, networkModule, featureModule)
}

koinViewModel() scopes ViewModel to navigation destination automatically with Navigation Compose integration.

Koin on Kotlin Multiplatform

Shared business logic with platform-specific modules:

// commonMain
val sharedModule = module {
    single { UserRepository(get()) }
    factory { GetUserUseCase(get()) }
}

// androidMain
val androidModule = module {
    single<DatabaseDriver> { AndroidDatabaseDriver(context) }
    single { PlatformLogger() }
}

// iosMain
val iosModule = module {
    single<DatabaseDriver> { NativeDatabaseDriver() }
    single { PlatformLogger() }
}

KMP DI without Hilt — single DSL across all platforms. Platform modules provide expect/actual implementations.

Koin testing

Replace modules in tests without Robolectric or instrumented tests:

class OrderViewModelTest : KoinTest {
    @get:Rule
    val koinTestRule = KoinTestRule.create {
        modules(testModule)
    }

    private val testModule = module {
        single { FakeOrderRepository() }
        viewModel { OrderViewModel(get()) }
    }

    @Test
    fun loadOrders_returnsList() = runTest {
        val vm = get<OrderViewModel>()
        vm.loadOrders()
        assertEquals(3, vm.orders.value.size)
    }
}

Runtime module swapping in tests — no @Mock annotations or manual constructor injection in test code.

Failure modes

Production checklist

Common production mistakes

Teams get dependency injection koin wrong in predictable ways:

Shipping dependency injection koin on Android fails quietly when you test only on flagship devices, skip process-death scenarios, or assume minSdk behavior matches latest API docs. Emulator-only validation misses OEM-specific battery optimizations and background execution limits.

Resources

Frequently asked questions

What is Koin and how does it differ from Hilt?

Koin is a lightweight DI framework for Kotlin using DSL and resolution by type, with no annotation processing or code generation. Hilt is Google's DI built on Dagger with compile-time validation and Android-specific scopes. Koin is faster to set up and has zero compile overhead; Hilt catches dependency errors at compile time and integrates deeply with Android lifecycle.

When should I choose Koin over Hilt?

Choose Koin for Kotlin Multiplatform projects (shared DI across Android and iOS), prototypes, or teams that prefer pure Kotlin DSL over annotations. Choose Hilt for large Android-only apps where compile-time safety, AssistedInject, and deep Android integration (WorkManager, Compose) matter.

How do I inject ViewModels with Koin?

Define ViewModels in a Koin module using viewModel { } or viewModelOf(::MyViewModel). Inject in Activities/Fragments with by viewModel() delegate, or in Compose with koinViewModel(). Koin automatically scopes ViewModels to the lifecycle owner.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →