What Koin does in a KMP app
Dependency injection (DI) means a class receives what it needs through its constructor instead of creating it. ScoreRepository receives a ScoreStore, and ScoreViewModel receives a ScoreRepository. Something still has to create those objects and wire them together. Koin is a library that does that part, and it runs in the commonMain of a Kotlin Multiplatform (KMP) project.
At the time of writing, the latest Koin release on Maven Central is 4.2.2. I built and tested the code in this post on Koin 4.2.2 with Kotlin 2.4.10.
Add the dependencies
Add Koin to commonMain:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.insert-koin:koin-core:4.2.2")
implementation("io.insert-koin:koin-compose-viewmodel:4.2.2")
}
}
}
You only need koin-compose-viewmodel if you get ViewModels in Compose with koinViewModel().
Write a module
A Koin module is a list of "when someone needs this type, build it like this". A small example with a score repository and a ViewModel:
// commonMain
package com.example.koin
import androidx.lifecycle.ViewModel
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
interface ScoreStore {
fun save(score: Int)
fun best(): Int
}
class InMemoryScoreStore : ScoreStore {
private var bestScore = 0
override fun save(score: Int) {
if (score > bestScore) bestScore = score
}
override fun best(): Int = bestScore
}
class ScoreRepository(private val store: ScoreStore) {
fun submit(score: Int) = store.save(score)
fun best(): Int = store.best()
}
class ScoreViewModel(private val repository: ScoreRepository) : ViewModel() {
fun best(): Int = repository.best()
}
expect fun platformModule(): Module
val appModule = module {
includes(platformModule())
singleOf(::ScoreRepository)
viewModelOf(::ScoreViewModel)
}
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
modules(appModule)
}
}
A few things worth noticing:
singlecreates one instance shared by the whole app.factory(not used here) creates a new instance every time it is requested.viewModel/viewModelOfcreates a ViewModel tied to a screen's lifecycle.singleOf(::ScoreRepository)reads the constructor and callsget()for each parameter. Written by hand it would besingle { ScoreRepository(get()) }. Both are plain Kotlin, with no annotation processor and no code generation step.includes(platformModule())pulls in each platform's own part. Every platform writesactual fun platformModule()returning its module, for example readingSharedPreferenceson Android andNSUserDefaultson iOS. To keep the example short, both useInMemoryScoreStore:
// androidMain and iosMain
actual fun platformModule(): Module = module {
single<ScoreStore> { InMemoryScoreStore() }
}
Start Koin on each platform
Call initKoin() once, where the app starts. On Android that is Application.onCreate():
class ScoreApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin()
}
}
On iOS, you call it from Swift at app launch. Kotlin top-level functions show up in Swift under a class named after the file, so if initKoin lives in Koin.kt, Swift calls KoinKt.doInitKoin(). Kotlin/Native adds the do prefix because Objective-C reserves names starting with init for initializers.
In Compose, get the ViewModel with koinViewModel():
@Composable
fun ScoreScreen(viewModel: ScoreViewModel = koinViewModel()) {
Text("Best: ${viewModel.best()}")
}
Swap a dependency in tests
Because ScoreRepository only knows the ScoreStore interface, a test can hand it a fake. With Koin, you load the real module and override exactly one definition:
// commonTest
class FakeScoreStore(private val fixedBest: Int) : ScoreStore {
override fun save(score: Int) = Unit
override fun best(): Int = fixedBest
}
class ScoreRepositoryKoinTest {
@AfterTest
fun tearDown() {
stopKoin()
}
@Test
fun repositoryReadsTheStoreKoinGaveIt() {
val koin = startKoin {
allowOverride(true)
modules(appModule, module { single<ScoreStore> { FakeScoreStore(fixedBest = 42) } })
}.koin
assertEquals(42, koin.get<ScoreRepository>().best())
}
}
Calling stopKoin() after each test is required. startKoin uses one global instance, and starting it a second time without stopping the first throws an error.
Catch missing definitions before running the app
With the DSL above, if you forget to declare ScoreStore, the app still builds and only crashes when Koin has to create ScoreRepository. Koin's verify() in koin-test catches this in a unit test. According to the Koin docs, verify() runs on the JVM only, so in KMP you put this test in the Android host test source set:
// androidHostTest, needs io.insert-koin:koin-test
@OptIn(KoinExperimentalAPI::class)
class AppModuleCheck {
@Test
fun appModuleIsComplete() {
appModule.verify()
}
}
I removed the single<ScoreStore> line from the Android module, and this test failed with MissingKoinDefinitionException. On Koin 4.2.2, verify() is still marked @KoinExperimentalAPI, which is why the @OptIn line is there.
Koin 4.2 also has a Compiler Plugin that checks the whole dependency graph at build time, and the Koin docs recommend it for new projects on Kotlin 2.3.20 or later. The plugin is optional. The DSL above works without it, and you can add the plugin later without rewriting your modules. If you have used Koin annotations through KSP: the KSP processor (koin-ksp-compiler) is deprecated, but the annotations themselves are not.
Keep learning
Kotlin Mobile 101 teaches dependency injection with Koin on a complete Kotlin Multiplatform app, with unit tests that run in a few seconds.