Koin — feature-scoped module structure
This skill covers how to organize Koin modules in a growing Android codebase. The core rule: as an app scales, a single monolithic viewModelModule listing every ViewModel becomes a merge-conflict magnet and a review-friction generator. Split by feature; keep infrastructure by concern.
Pairs with [[wnb-viewmodel-udf]] — the VM shape the module binds.
Non-negotiables
- Feature-scoped modules for feature code. One
Module per feature package (customerModule, sellerModule, authModule, adminModule, …). Each module bundles that feature's ViewModels and any bindings only that feature uses.
- Concern-scoped modules for cross-cutting infrastructure.
databaseModule, ktorModule / firebaseModule, dispatcherModule, preferencesModule, connectivityModule, workerModule. These stay concern-scoped because they are consumed by every feature.
viewModelOf(::XxxViewModel) when the constructor is Koin-injectable end-to-end. Only fall back to viewModel { XxxViewModel(get(), get(named("foo")), get()) } when you need qualifiers, SavedStateHandle, or manual argument massaging.
single vs factory vs viewModel:
single { } — one instance per Koin scope. Use for repositories, DAOs, HTTP clients, dispatcher providers.
factory { } — new instance every get(). Use for lightweight helpers you don't want to leak state across.
viewModel { } / viewModelOf(...) — one instance per ViewModelStoreOwner. Never single a ViewModel.
named("...") qualifier when two bindings share a type. Example: two DataStore<Preferences> instances (named("pendingSync"), named("session")). Consumers must use the same qualifier at get(named("...")).
- All modules merged into a single
appModules: List<Module>. One import point.
startKoin { modules(appModules) } only in Application.onCreate(). Never anywhere else. Tests use loadKoinModules / unloadKoinModules inside a KoinTestRule, not startKoin.
- No circular module dependencies. Feature modules depend on infrastructure modules; infrastructure never depends on features. If a "cross-feature" binding is needed, promote it to a concern module.
Canonical shape
// di/CustomerModule.kt — feature-scoped
val customerModule = module {
// ViewModels for this feature
viewModelOf(::CustomerHomeViewModel)
viewModelOf(::CustomerProductDetailViewModel)
viewModelOf(::CartViewModel)
viewModelOf(::WishlistViewModel)
viewModel {
CheckoutViewModel(
paymentRepository = get(),
cartRepository = get(),
addressRepository = get(),
authRepository = get(),
connectivityObserver = get(),
discountRepository = get(),
dispatcherProvider = get(),
)
}
// Feature-only helper — not used outside customer package
single { CustomerPricingFormatter(get()) }
}
// di/DataModule.kt — concern-scoped: repositories are consumed by every feature
val repositoryModule = module {
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<CartRepository> { CartRepositoryImpl(get(), get()) }
single<AddressRepository> { AddressRepositoryImpl(get(), get()) }
single<PaymentRepository> { PaymentRepositoryImpl(get(), get(), get()) }
single<DiscountRepository> { DiscountRepositoryImpl(get(), get()) }
}
// di/DispatcherModule.kt — the injectable IO/Main/Default abstraction
val dispatcherModule = module {
single<DispatcherProvider> { DefaultDispatcherProvider() }
}
// di/PreferencesModule.kt — named qualifiers for parallel DataStore bindings
val preferencesModule = module {
single(named("session")) {
PreferenceDataStoreFactory.create { get<Context>().preferencesDataStoreFile("session") }
}
single(named("pendingSync")) {
PreferenceDataStoreFactory.create { get<Context>().preferencesDataStoreFile("pending_sync") }
}
}
// di/AppModules.kt — single composition point
val appModules = listOf(
// Infrastructure (concern-scoped)
firebaseModule,
databaseModule,
dispatcherModule,
preferencesModule,
connectivityModule,
workerModule,
ktorModule,
notificationModule,
repositoryModule,
// Features (feature-scoped)
authModule,
customerModule,
sellerModule,
adminModule,
)
// App.kt — the only place startKoin appears
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@App)
modules(appModules)
}
}
}
Common mistakes
- A single
viewModelModule that lists every ViewModel in the app — canonical merge-conflict magnet, and impossible to see at a glance "what does this feature own?". Split by feature.
- A single
dataModule that binds every repository and every DAO — same issue at a smaller scale. Split at least by layer (repositoryModule, databaseModule, networkModule).
single<XxxViewModel>() — a ViewModel is never a singleton. Use viewModel / viewModelOf. single will outlive the screen and leak.
- Two
DataStore<Preferences> bindings without named(...) — Koin can't disambiguate; you get a runtime NoBeanDefFoundException or, worse, the wrong instance. Always qualify parallel bindings.
- Feature module importing another feature module — cross-feature coupling in the DI graph. Promote the shared binding to a concern module, or split it out (
sharedCommerceModule).
get() inside a Composable body — Koin lookups are runtime; use koinInject<T>() / koinViewModel<T>() from koin-androidx-compose. Or hoist the injection to the ViewModel and pass state down.
startKoin in a test — collides with the running App's Koin. Use KoinTestRule + modules(testAuthModule, testDataModule) where the test modules override the real ones.
- Forgetting to append a new module to
appModules — the app compiles, then throws at runtime the first time the missing binding is requested. Add the module to appModules in the same commit.
- Circular dependency (
customerModule depends on sellerModule binding X; sellerModule depends on customerModule binding Y). Koin fails at graph construction. Refactor X and Y into a shared concern module.
- Injecting a
DispatcherProvider on every consumer but binding Dispatchers.IO directly somewhere — split brain. One canonical DispatcherProvider binding lives in dispatcherModule.
Testing the DI graph
- Static verification: call
verify() on each feature module in a JVM unit test. Fails fast if a binding is missing.
- Overriding for tests:
KoinTestRule + modules(testXxxModule) where the test module rebinds specific single<XxxRepository> { FakeXxxRepository() }. Prefer overriding at the repository layer, not the ViewModel layer — ViewModels should be constructed directly in tests (see [[wnb-viewmodel-test]]), not resolved through Koin.
- Dynamic loading:
loadKoinModules(testAuthModule) and unloadKoinModules(testAuthModule) in @Before / @After when the test needs to swap a binding mid-suite.
Related skills
[[wnb-viewmodel-udf]] — the ViewModel shape these modules bind.
[[wnb-viewmodel-test]] — why VMs are constructed directly in tests, not resolved through Koin.
1---2name: wnb-koin-feature-module3description: Use this skill when adding a new feature to an Android + Koin project, or when reviewing dependency injection wiring. Enforces feature-scoped Koin modules — one `Module` per feature package (customerModule, sellerModule, authModule, …) that bundles the feature's ViewModels and feature-only bindings, alongside concern-scoped modules (databaseModule, ktorModule, dispatcherModule, firebaseModule) for cross-cutting infrastructure. Requires `viewModelOf(::XxxViewModel)` for simple constructors, `viewModel { … }` for manual wiring, `single` vs `factory` semantics, `named(...)` qualifier for parallel bindings of the same type, all modules merged into a single `appModules` list, `startKoin { modules(appModules) }` only in `Application.onCreate`. Triggers on "add koin module", "koin binding", "viewModelOf", "koin module", "single vs factory", "named qualifier", "startKoin", "loadKoinModules", "feature module", "DI wiring", "inject viewmodel".4---56# Koin — feature-scoped module structure78This skill covers **how to organize Koin modules in a growing Android codebase**. The core rule: as an app scales, a single monolithic `viewModelModule` listing every ViewModel becomes a merge-conflict magnet and a review-friction generator. Split by feature; keep infrastructure by concern.910Pairs with `[[wnb-viewmodel-udf]]` — the VM shape the module binds.1112## Non-negotiables13141. **Feature-scoped modules for feature code.** One `Module` per feature package (`customerModule`, `sellerModule`, `authModule`, `adminModule`, …). Each module bundles that feature's ViewModels *and* any bindings only that feature uses.152. **Concern-scoped modules for cross-cutting infrastructure.** `databaseModule`, `ktorModule` / `firebaseModule`, `dispatcherModule`, `preferencesModule`, `connectivityModule`, `workerModule`. These stay concern-scoped because they are consumed by every feature.163. **`viewModelOf(::XxxViewModel)` when the constructor is Koin-injectable end-to-end.** Only fall back to `viewModel { XxxViewModel(get(), get(named("foo")), get()) }` when you need qualifiers, `SavedStateHandle`, or manual argument massaging.174. **`single` vs `factory` vs `viewModel`:**18 - `single { }` — one instance per Koin scope. Use for repositories, DAOs, HTTP clients, dispatcher providers.19 - `factory { }` — new instance every `get()`. Use for lightweight helpers you don't want to leak state across.20 - `viewModel { }` / `viewModelOf(...)` — one instance per `ViewModelStoreOwner`. Never `single` a ViewModel.215. **`named("...")` qualifier when two bindings share a type.** Example: two `DataStore<Preferences>` instances (`named("pendingSync")`, `named("session")`). Consumers must use the same qualifier at `get(named("..."))`.226. **All modules merged into a single `appModules: List<Module>`.** One import point.237. **`startKoin { modules(appModules) }` only in `Application.onCreate()`.** Never anywhere else. Tests use `loadKoinModules` / `unloadKoinModules` inside a `KoinTestRule`, not `startKoin`.248. **No circular module dependencies.** Feature modules depend on infrastructure modules; infrastructure never depends on features. If a "cross-feature" binding is needed, promote it to a concern module.2526## Canonical shape2728```kotlin29// di/CustomerModule.kt — feature-scoped30val customerModule = module {31 // ViewModels for this feature32 viewModelOf(::CustomerHomeViewModel)33 viewModelOf(::CustomerProductDetailViewModel)34 viewModelOf(::CartViewModel)35 viewModelOf(::WishlistViewModel)3637 viewModel {38 CheckoutViewModel(39 paymentRepository = get(),40 cartRepository = get(),41 addressRepository = get(),42 authRepository = get(),43 connectivityObserver = get(),44 discountRepository = get(),45 dispatcherProvider = get(),46 )47 }4849 // Feature-only helper — not used outside customer package50 single { CustomerPricingFormatter(get()) }51}5253// di/DataModule.kt — concern-scoped: repositories are consumed by every feature54val repositoryModule = module {55 single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }56 single<CartRepository> { CartRepositoryImpl(get(), get()) }57 single<AddressRepository> { AddressRepositoryImpl(get(), get()) }58 single<PaymentRepository> { PaymentRepositoryImpl(get(), get(), get()) }59 single<DiscountRepository> { DiscountRepositoryImpl(get(), get()) }60}6162// di/DispatcherModule.kt — the injectable IO/Main/Default abstraction63val dispatcherModule = module {64 single<DispatcherProvider> { DefaultDispatcherProvider() }65}6667// di/PreferencesModule.kt — named qualifiers for parallel DataStore bindings68val preferencesModule = module {69 single(named("session")) {70 PreferenceDataStoreFactory.create { get<Context>().preferencesDataStoreFile("session") }71 }72 single(named("pendingSync")) {73 PreferenceDataStoreFactory.create { get<Context>().preferencesDataStoreFile("pending_sync") }74 }75}7677// di/AppModules.kt — single composition point78val appModules = listOf(79 // Infrastructure (concern-scoped)80 firebaseModule,81 databaseModule,82 dispatcherModule,83 preferencesModule,84 connectivityModule,85 workerModule,86 ktorModule,87 notificationModule,88 repositoryModule,89 // Features (feature-scoped)90 authModule,91 customerModule,92 sellerModule,93 adminModule,94)9596// App.kt — the only place startKoin appears97class App : Application() {98 override fun onCreate() {99 super.onCreate()100 startKoin {101 androidLogger()102 androidContext(this@App)103 modules(appModules)104 }105 }106}107```108109## Common mistakes110111- **A single `viewModelModule` that lists every ViewModel in the app** — canonical merge-conflict magnet, and impossible to see at a glance "what does this feature own?". Split by feature.112- **A single `dataModule` that binds every repository and every DAO** — same issue at a smaller scale. Split at least by layer (`repositoryModule`, `databaseModule`, `networkModule`).113- **`single<XxxViewModel>()`** — a ViewModel is never a singleton. Use `viewModel` / `viewModelOf`. `single` will outlive the screen and leak.114- **Two `DataStore<Preferences>` bindings without `named(...)`** — Koin can't disambiguate; you get a runtime `NoBeanDefFoundException` or, worse, the wrong instance. Always qualify parallel bindings.115- **Feature module importing another feature module** — cross-feature coupling in the DI graph. Promote the shared binding to a concern module, or split it out (`sharedCommerceModule`).116- **`get()` inside a Composable body** — Koin lookups are runtime; use `koinInject<T>()` / `koinViewModel<T>()` from `koin-androidx-compose`. Or hoist the injection to the ViewModel and pass state down.117- **`startKoin` in a test** — collides with the running `App`'s Koin. Use `KoinTestRule` + `modules(testAuthModule, testDataModule)` where the test modules override the real ones.118- **Forgetting to append a new module to `appModules`** — the app compiles, then throws at runtime the first time the missing binding is requested. Add the module to `appModules` in the same commit.119- **Circular dependency** (`customerModule` depends on `sellerModule` binding X; `sellerModule` depends on `customerModule` binding Y). Koin fails at graph construction. Refactor X and Y into a shared concern module.120- **Injecting a `DispatcherProvider` on every consumer but binding `Dispatchers.IO` directly somewhere** — split brain. One canonical `DispatcherProvider` binding lives in `dispatcherModule`.121122## Testing the DI graph123124- **Static verification:** call `verify()` on each feature module in a JVM unit test. Fails fast if a binding is missing.125- **Overriding for tests:** `KoinTestRule` + `modules(testXxxModule)` where the test module rebinds specific `single<XxxRepository> { FakeXxxRepository() }`. Prefer overriding at the repository layer, not the ViewModel layer — ViewModels should be constructed directly in tests (see `[[wnb-viewmodel-test]]`), not resolved through Koin.126- **Dynamic loading:** `loadKoinModules(testAuthModule)` and `unloadKoinModules(testAuthModule)` in `@Before` / `@After` when the test needs to swap a binding mid-suite.127128## Related skills129130- `[[wnb-viewmodel-udf]]` — the ViewModel shape these modules bind.131- `[[wnb-viewmodel-test]]` — why VMs are constructed directly in tests, not resolved through Koin.