name: dependency-injection description: Wire Android/KMP apps with Koin 4.x or Hilt — module declarations, scopes, qualifiers, ViewModel injection, KMP multiplatform modules, and testing with fakes argument-hint: "" user-invocable: true
// shared/build.gradle.kts
dependencies {
implementation(libs.koin.core)
implementation(libs.koin.compose) // koin-compose
implementation(libs.koin.compose.viewmodel) // koinViewModel()
}
// androidApp/build.gradle.kts
dependencies {
implementation(libs.koin.android)
}
// commonMain — shared business logic DI
val domainModule = module {
// Use Cases — factory (new instance each time)
factory { GetDueWordsUseCase(get()) }
factory { ReviewWordUseCase(get(), get()) }
factory { SaveWordUseCase(get()) }
// Domain Services — single (shared, stateless)
single { SpacedRepetitionService() }
}
val dataModule = module {
// Repository — single (shared state across features)
// Impl returns Try<T> as mandated by the layer contract
single<IWordRepository> { WordRepositoryImpl(get(), get()) }
single<IUserRepository> { UserRepositoryImpl(get(), get()) }
// DataSources — single
single<IWordLocalDataSource> { WordLocalDataSourceImpl(get()) }
single<IWordRemoteDataSource> { WordRemoteDataSourceImpl(get()) }
}
val networkModule = module {
single { createHttpClient(get()) }
}
// commonMain — declare with viewModel {}
val presentationModule = module {
viewModel { WordListViewModel(get(), get()) }
viewModel { StudyViewModel(get(), get(), get()) }
// SavedStateHandle is injected automatically by Koin
viewModel { WordDetailViewModel(get(), get<SavedStateHandle>()) }
}
// Screen — inject with koinViewModel()
@Composable
fun WordListScreen() {
val viewModel = koinViewModel<WordListViewModel>()
// ...
}
// Screen with navigation argument via SavedStateHandle
@Composable
fun WordDetailScreen() {
// wordId read from SavedStateHandle inside the ViewModel
val viewModel = koinViewModel<WordDetailViewModel>()
}
expect/actual Pattern// commonMain — declare the contract
expect val platformModule: Module
// androidMain
actual val platformModule: Module = module {
single<HttpClientEngine> { OkHttp.create() }
single { DatabaseDriverFactory(androidContext()) }
single<IPlatformNotifications> { AndroidNotificationsImpl(androidContext()) }
single<ISecureStorage> { AndroidSecureStorageImpl(androidContext()) }
}
// iosMain
actual val platformModule: Module = module {
single<HttpClientEngine> { Darwin.create() }
single { DatabaseDriverFactory() }
single<IPlatformNotifications> { IosNotificationsImpl() }
single<ISecureStorage> { IosKeychainStorageImpl() }
}
// Android — Application.onCreate()
class LexiconApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@LexiconApp)
androidLogger(Level.ERROR) // Level.DEBUG in development
modules(
domainModule,
dataModule,
networkModule,
presentationModule,
platformModule, // resolves to androidMain actual
)
}
}
}
// iOS — Swift entry point (call from Swift AppDelegate / @main)
fun initKoin() {
startKoin {
modules(
domainModule,
dataModule,
networkModule,
presentationModule,
platformModule, // resolves to iosMain actual
)
}
}
// Swift — AppDelegate.swift
@main
struct LexiconApp: App {
init() { KoinKt.doInitKoin() }
}
// Define scope
val featureModule = module {
scope<StudyScope> {
scoped { StudySessionManager(get()) }
scoped { StreakTracker(get()) }
viewModel { StudyViewModel(get(), get()) }
}
}
// Android — create and close with lifecycle
class StudyActivity : AppCompatActivity() {
private val scope: Scope by lazy {
getKoin().createScope<StudyScope>()
}
override fun onDestroy() {
super.onDestroy()
scope.close()
}
}
includes()Use includes() to reuse modules inside feature modules without re-declaring bindings.
val studyFeatureModule = module {
includes(domainModule, dataModule)
viewModel { StudyViewModel(get()) }
}
// Define qualifier constants
val IoDispatcher = named("IoDispatcher")
val DefaultDispatcher = named("DefaultDispatcher")
val MainDispatcher = named("MainDispatcher")
val dispatchersModule = module {
single(IoDispatcher) { Dispatchers.IO }
single(DefaultDispatcher) { Dispatchers.Default }
single(MainDispatcher) { Dispatchers.Main }
}
// In module
single<IWordRepository> {
WordRepositoryImpl(
ioDispatcher = get(IoDispatcher),
)
}
Use KoinComponent only in platform/infrastructure layer classes — never in domain.
// data / platform layer only
class WordNotificationService : KoinComponent {
private val repository: IWordRepository by inject() // lazy
private val settings: AppSettings by inject()
}
// Direct get — resolve immediately
class AppInitializer : KoinComponent {
fun init() {
val settings: AppSettings = get()
settings.migrate()
}
}
Catch missing bindings at test time — run once per module set.
class KoinGraphTest : KoinTest {
@Test
fun `verify full koin graph`() {
val app = KoinApplication.init()
app.modules(domainModule, dataModule, networkModule, presentationModule, platformModule)
app.checkModules()
}
}
// Best — direct construction, no Koin overhead
class WordListViewModelTest {
private val fakeRepo = FakeWordRepository()
private val useCase = GetDueWordsUseCase(fakeRepo)
private val vm = WordListViewModel(useCase)
@Test
fun `loading state transitions correctly`() = runTest { ... }
}
class WordListIntegrationTest : KoinTest {
@Before
fun setUp() {
startKoin {
modules(
module {
single<IWordRepository> { FakeWordRepository() }
single { GetDueWordsUseCase(get()) }
viewModel { WordListViewModel(get()) }
}
)
}
}
@After
fun tearDown() = stopKoin()
@Test
fun `loading state transitions correctly`() = runTest {
val viewModel: WordListViewModel by inject()
// test...
}
}
// app/build.gradle.kts
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.build()
@Provides @Singleton
fun provideWordApiService(client: OkHttpClient): WordApiService =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(client)
.addConverterFactory(kotlinx.serialization converter)
.build()
.create(WordApiService::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindWordRepository(impl: WordRepositoryImpl): IWordRepository
}
@HiltViewModel
class WordListViewModel @Inject constructor(
private val getDueWords: GetDueWordsUseCase,
private val deleteWord: DeleteWordUseCase,
) : ViewModel() { ... }
// ViewModel with navigation argument via SavedStateHandle
@HiltViewModel
class WordDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, // injected automatically from NavBackStackEntry
private val getWord: GetWordUseCase,
) : ViewModel() {
private val wordId: Int = checkNotNull(savedStateHandle["wordId"])
}
// Screen — no factory needed
@Composable
fun WordListScreen(
viewModel: WordListViewModel = hiltViewModel(),
) { ... }
// Activity-scoped — destroyed with activity
@Module
@InstallIn(ActivityComponent::class)
object ActivityModule { ... }
// ViewModel-scoped — destroyed with ViewModel
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule { ... }
@HiltAndroidTest
class WordListViewModelTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val mainDispatcherRule = MainDispatcherRule()
// Field injection is required here — Hilt test infrastructure does not support
// constructor injection for @HiltAndroidTest classes
@Inject lateinit var viewModel: WordListViewModel
@Before
fun setUp() = hiltRule.inject()
}
// Replace bindings in tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class],
)
abstract class FakeRepositoryModule {
@Binds @Singleton
abstract fun bindFakeWordRepository(fake: FakeWordRepository): IWordRepository
}
DI wires implementations to interfaces — the return types must match the layer contract from CLAUDE.md:
| Binding | Interface Return Type |
|---|---|
IWordRepository.getWord() |
Try<Word> |
IWordRepository.observeWords() |
Flow<List<Word>> |
| UseCase | Try<T> or Flow<T> |
Never let the DI module paper over a missing Try<T> — the interface must enforce it.
| Need | Choice |
|---|---|
| KMP (Android + iOS) | Koin 4.x + expect/actual platform modules |
| Android-only, large team | Hilt (compile-time safety) |
| Android-only, simple app | Koin (less boilerplate) |
| Unit testing | Constructor injection — no DI framework |
Context into domain or data layer classes — pass at app boundaryGlobalContext.get() / KoinComponent in domain classes — breaks portability@HiltAndroidTest is the sole exception)single {} for ViewModel — use viewModel {} for proper scopecheckModules() in CI — a missing binding will crash at runtime, not compile time