$npx -y skills add rcosteira79/android-skills --skill koinUse when setting up or working with Koin in Android or KMP projects — module declarations with Classic DSL or KSP annotations, ViewModel injection in Compose, scopes, Nav 3 entry providers, application startup, and compile-time verification via verify(). Triggers on Koin, `sing
| 1 | # Koin Dependency Injection (Android and KMP) |
| 2 | |
| 3 | Pragmatic Kotlin DI. **Koin vs Hilt:** Koin runs in `commonMain` (Hilt doesn't) and verifies the graph via `verify()` instead of codegen; Hilt is Android-only with codegen + deep Jetpack integration (`@HiltViewModel` / `hiltViewModel()`). This reference covers the traps and boundaries, not the Classic DSL basics (`single` / `factory` / `scoped` / `viewModel { }` with `get()` resolution and runtime params, `koinViewModel` / `koinInject`, `startKoin { androidLogger(); androidContext(this@MyApp); modules(...) }`). **Related:** `android-skills:kmp-ktor`, `android-skills:android-data-layer`. Deps via `koin-bom`: `koin-core`, `koin-android`, `koin-androidx-compose`, `koin-compose-viewmodel` (KMP), `koin-compose-viewmodel-navigation` (Nav 3), `koin-test`, + optional `koin-annotations` / `koin-ksp-compiler`. |
| 4 | |
| 5 | ## KSP Annotations — the generated-module accessor |
| 6 | |
| 7 | `@Module @ComponentScan` discovers annotated classes and emits a module you pass to `startKoin` as **`UserModule().module`** — the generated `.module` extension on the annotated class is the part most often missed. Pick one style per module (don't mix Classic DSL + annotations inside a single module; switching *between* modules is fine). |
| 8 | |
| 9 | ```kotlin |
| 10 | @Single class UserRepositoryImpl(private val service: UserService) : UserRepository |
| 11 | @Factory class UserFormValidator |
| 12 | @KoinViewModel class UserDetailViewModel( |
| 13 | @InjectedParam private val userId: String, |
| 14 | private val repository: UserRepository, |
| 15 | ) : ViewModel() |
| 16 | @Module @ComponentScan("com.example.feature.user") class UserModule |
| 17 | // startKoin { modules(UserModule().module) } |
| 18 | ``` |
| 19 | |
| 20 | ## KMP source-set layout |
| 21 | |
| 22 | Most bindings live in `commonMain`; platform-typed ones (HTTP engine, `Context`, Keychain) go behind `expect val platformModule: Module` with an `actual` per platform (mirrors `android-skills:kmp-ktor`'s engine pattern). iOS startup: call `InitKoinKt.doInitKoin(config: nil)` from `iOSApp.init()` — Swift reserves `init`, hence the `do` prefix. |
| 23 | |
| 24 | ```kotlin |
| 25 | // commonMain |
| 26 | expect val platformModule: Module |
| 27 | // androidMain: actual = module { single<HttpClientEngine> { OkHttp.create() }; single<TokenStorage> { DataStoreTokenStorage(get()) } } |
| 28 | // iosMain: actual = module { single<HttpClientEngine> { Darwin.create() }; single<TokenStorage> { KeychainTokenStorage() } } |
| 29 | ``` |
| 30 | |
| 31 | ## Compose: the call-site failure |
| 32 | |
| 33 | `koinViewModel` / `koinInject` require `koin-androidx-compose` (Android) or `koin-compose-viewmodel` (KMP). Without that artifact the call **compiles but fails at the call site** — `koin-core` knows nothing about `ViewModel` or the Compose runtime. For testability, default composable params to `koinInject()`: `fun Screen(service: AnalyticsService = koinInject())`. |
| 34 | |
| 35 | ```kotlin |
| 36 | val vm: UserDetailViewModel = koinViewModel { parametersOf(userId) } |
| 37 | // keyed per entity: koinViewModel(key = "detail_$userId", parameters = { parametersOf(userId) }) |
| 38 | ``` |
| 39 | |
| 40 | ## Scopes |
| 41 | |
| 42 | **Survives configuration changes (the common case) → `activityRetainedScope`** — backed by the Activity's retained `ViewModel`, so it lives across rotation and closes on real finish. Declare with `activityRetainedScope { }` (or `@ActivityRetainedScope`) and access via `AndroidScopeComponent`; do **not** hand-roll `createScope` / `close` for this case. |
| 43 | |
| 44 | ```kotlin |
| 45 | val checkoutModule = module { |
| 46 | activityRetainedScope { // retained across config changes |
| 47 | scoped { CheckoutCart() } |
| 48 | scoped { CheckoutPricing(get()) } |
| 49 | } |
| 50 | } |
| 51 | class CheckoutActivity : AppCompatActivity(), AndroidScopeComponent { |
| 52 | override val scope: Scope by activityRetainedScope() |
| 53 | private val cart: CheckoutCart by inject() |
| 54 | } |
| 55 | ``` |
| 56 | |
| 57 | **Custom lifetime not tied to an Activity** (e.g. a multi-step flow keyed by an order id) → declare a `scope<T>` and own its lifecycle: `getKoin().createScope<CheckoutFlow>("checkout-$orderId")`, resolve with `scope.get()`, and `scope.close()` when the flow ends. |
| 58 | |
| 59 | ## Nav 3 |
| 60 | |
| 61 | `koinEntryProvider()` resolves ViewModels per destination; register destinations inside modules, not inline at the `NavDisplay` site. |
| 62 | |
| 63 | ```kotlin |
| 64 | val navigationModule = module { |
| 65 | navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) } |
| 66 | navigation<DetailRoute> { route -> DetailScreen(viewModel = koinViewModel { parametersOf(route.id) }) } |
| 67 | } |
| 68 | NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = koinEntryProvider()) |
| 69 | ``` |
| 70 | |
| 71 | ## Testing — `verify()` / `checkModules()` |
| 72 | |
| 73 | Walks each declaration's constructor and confirms every dependency is declared — missing bindings become **tes |