Dan Tech Academy

Koin in Kotlin Multiplatform: lightweight dependency injection without code generation

Write a Koin 4.2 module for commonMain, start it on Android and iOS, swap dependencies in tests and catch missing definitions with verify(). Code built on Kotlin 2.4.

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:

  • single creates one instance shared by the whole app. factory (not used here) creates a new instance every time it is requested. viewModel/viewModelOf creates a ViewModel tied to a screen's lifecycle.
  • singleOf(::ScoreRepository) reads the constructor and calls get() for each parameter. Written by hand it would be single { 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 writes actual fun platformModule() returning its module, for example reading SharedPreferences on Android and NSUserDefaults on iOS. To keep the example short, both use InMemoryScoreStore:
// 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.

Reading is fun. A roadmap gets you there.

The Kotlin Android Roadmap sorts the Android Mastery, OOP and Design Patterns posts into 5 levels - from your first line of Kotlin to an app on the store.

Plus a new post every week. Unsubscribe anytime.