Dependency Injection in KMP: Koin, Metro and Manual DI
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Every non-trivial app has the same wiring problem: TrailListViewModel needs a TrailRepository, which needs a
TrailApiClient, a TrailMateDatabase and a clock; somebody has to build these objects once, share them where
they must be shared, and hand them to the screens. On Android the answer is usually Hilt. In Kotlin Multiplatform
that answer is not available, because Dagger generates Java code through annotation processing and cannot be
compiled for iOS, desktop-native or web targets. This page explains what you use instead, and when.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1,
AGP 9.4, Ktor 3.6.0, SQLDelight 2.4.0 and Koin 4.2.2; for iOS interop SKIE 0.10.15 and
KMP-NativeCoroutines 1.0.6 (Swift export and SwiftPM import are Alpha; Wasm and Compose Multiplatform for web are
Beta); for tests Mokkery 3.5.0 and Turbine 1.2.1. Metro 1.4.5 and kotlin-inject 0.9.0 are the
compile-time alternatives covered. Every example is a slice of TrailMate Everywhere, the multiplatform version of
the Android section’s TrailMate app (package com.example.trailmate). The Android-only
treatment of Dagger, Hilt, Koin and Metro is on
Dependency Injection: Hilt and Koin; this page does not
repeat it and concentrates on what changes once code is shared.
Why Hilt and Dagger Do Not Apply
Hilt and Dagger are JVM tools. Dagger’s annotation processor (kapt or KSP) emits Java factories and components,
Hilt additionally rewrites Android classes with a Gradle bytecode transform, and both rely on javax.inject and
Android lifecycle components such as @AndroidEntryPoint. None of that exists in commonMain: Kotlin/Native
produces no JVM bytecode, there is no Activity on iOS, and a common source set cannot reference Java types.
| Aspect | Why it fails in shared code | What you need instead |
|---|---|---|
Code generation |
Dagger emits Java sources and needs a JVM classpath. |
A multiplatform runtime library (Koin), or a Kotlin compiler plugin or KSP processor that emits Kotlin (Metro, kotlin-inject). |
Android components |
|
Constructor injection in plain Kotlin classes, with the platform entry point (an |
Platform objects |
A |
A per-platform module, supplied through expect/actual or a parameter, as in the expect/actual page. |
iOS consumers |
Swift cannot see Dagger components at all. |
A small Kotlin facade that exposes the already-built objects to Swift. |
A project may keep Hilt in androidApp for Android-only classes (a Worker, a Service), but anything in
sharedLogic must be wired with something multiplatform. Mixing two containers in one app is possible, yet it
doubles the places where a binding can live and is rarely worth it.
Option Zero: Manual Constructor Injection
Dependency injection is a pattern, not a framework: a class declares what it needs as constructor parameters and never builds or looks up its collaborators. The simplest correct DI for a multiplatform app is therefore a hand-written composition root — one place that creates the object graph.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/AppContainer.kt
package com.example.trailmate.di
import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailApiClient
import com.example.trailmate.data.TrailRepository
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.presentation.TrailListViewModel
import io.ktor.client.HttpClient
class AppContainer(
httpClient: HttpClient,
database: TrailMateDatabase,
) {
private val apiClient = TrailApiClient(httpClient, baseUrl = "https://api.example.com/v1/")
val trailRepository: TrailRepository =
OfflineFirstTrailRepository(api = apiClient, database = database)
// A factory method: every screen gets its own ViewModel instance.
fun trailListViewModel() = TrailListViewModel(trailRepository)
}
The two platform-specific inputs, the HttpClient (with its engine) and the TrailMateDatabase (with its
driver), arrive as constructor parameters. Android builds them with OkHttp and AndroidSqliteDriver, iOS with Darwin
and NativeSqliteDriver; AppContainer neither knows nor cares.
Manual DI has real strengths: it is explicit, compiles to ordinary Kotlin, has no startup cost and no runtime failure mode (a missing dependency is a compile error). It also scales further than people expect. Its weaknesses appear around 30 to 50 bindings: the container grows by hand, scoping (per-screen, per-session) must be coded manually, and forgetting to share one instance silently creates two.
Koin 4.2: the Default Choice
Koin is a lightweight DI framework written in pure Kotlin with a small DSL. It supports Android, iOS, desktop JVM, Kotlin/JS and Wasm, which is why it is the most common DI choice in KMP projects. It resolves dependencies at runtime: the DSL registers factory lambdas in a registry, and the first request for a type runs the lambda. That makes it trivial to set up and very flexible, at the price of errors surfacing at runtime unless you verify the graph in a test (see below).
// gradle/libs.versions.toml (excerpt)
[versions]
koin = "4.2.2"
[libraries]
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-test = { module = "io.insert-koin:koin-test" }
// sharedLogic/build.gradle.kts (excerpt)
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
}
kotlin {
android { namespace = "com.example.trailmate.shared"; compileSdk = 37; minSdk = 24 }
iosArm64(); iosSimulatorArm64(); jvm("desktop"); wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
api(libs.koin.core) // api: the iOS framework and Swift need KoinComponent
}
androidMain.dependencies { implementation(libs.koin.android) }
commonTest.dependencies { implementation(libs.koin.test) }
}
}
koin-core is the multiplatform artifact; koin-android adds androidContext() and Android lifecycle scopes;
koin-compose and koin-compose-viewmodel belong in sharedUI (shown later). The BOM keeps the versions aligned.
Declaring koin-core with api matters on iOS: Swift code that wants to resolve objects through Koin needs the
Koin classes to be exported in the framework (see Gradle Configuration).
Modules and Definitions
A Koin module is a named set of definitions. Each definition tells Koin how to build a type and with which lifetime:
| Keyword | Lifetime | Typical TrailMate use |
|---|---|---|
|
One instance for the lifetime of the Koin container (a singleton). |
|
|
A new instance on every request. |
Small stateless helpers, use cases, a formatter configured per call site. |
|
A new ViewModel per owner, cached by the platform’s |
|
|
One instance per Koin scope, which you open and close explicitly. |
Per-login-session data, or a per-screen cache (see the Android page for scopes). |
TrailMate’s module is written once in commonMain and used by every platform.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/TrailMateModule.kt
package com.example.trailmate.di
import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailApiClient
import com.example.trailmate.data.TrailRepository
import com.example.trailmate.presentation.TrailDetailViewModel
import com.example.trailmate.presentation.TrailListViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
import org.koin.dsl.module
val trailMateModule = module {
// Explicit lambda form: full control over constructor arguments.
single { TrailApiClient(client = get<HttpClient>(), baseUrl = "https://api.example.com/v1/") }
// Constructor DSL: Koin resolves each constructor parameter by type.
singleOf(::OfflineFirstTrailRepository) bind TrailRepository::class
viewModelOf(::TrailListViewModel)
viewModel { params -> TrailDetailViewModel(trailId = params.get(), repository = get()) }
}
Reading the module top to bottom: the first line shows the lambda form, where get() asks the container for a
dependency (the type is inferred from the parameter). singleOf(::Constructor) is the constructor DSL: Koin
inspects the constructor and calls get() for every parameter, which removes the boilerplate; bind registers
the same instance under the interface TrailRepository, so consumers ask for the interface and tests can replace
it. viewModelOf is the same idea for ViewModels, and the last line shows parameters: params.get() receives
the trailId passed at the call site (koinViewModel(parameters = \{ parametersOf(id) })).
Rules of thumb: inject interfaces, not implementations; prefer singleOf and viewModelOf unless an argument
needs a literal; never call get() outside a definition lambda (that turns Koin into a service locator spread
across your code).
Platform Modules Through expect/actual
Some bindings exist only per platform: the SQLDelight driver, the Ktor engine, LocationProvider. The standard
idiom is an expected module, implemented once per platform, which the shared module list then includes. The
expect/actual mechanics are covered on
expect/actual and Calling Platform APIs; here
the module also supplies the database and the HTTP engine.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/PlatformModule.kt
package com.example.trailmate.di
import org.koin.core.module.Module
expect val platformModule: Module
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/di/PlatformModule.android.kt
package com.example.trailmate.di
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.location.AndroidLocationProvider
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
actual val platformModule = module {
single<HttpClient> { HttpClient(OkHttp) }
single<SqlDriver> { AndroidSqliteDriver(TrailMateDatabase.Schema, androidContext(), "trailmate.db") }
single<LocationProvider> { AndroidLocationProvider(androidContext()) }
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/di/PlatformModule.ios.kt
package com.example.trailmate.di
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.location.IosLocationProvider
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import org.koin.dsl.module
actual val platformModule = module {
single<HttpClient> { HttpClient(Darwin) }
single<SqlDriver> { NativeSqliteDriver(TrailMateDatabase.Schema, "trailmate.db") }
single<LocationProvider> { IosLocationProvider() }
}
Because the actual for iOS lives in iosMain, a single declaration serves iosArm64 and iosSimulatorArm64.
desktopMain and wasmJsMain need their own actuals as well, otherwise those targets fail to compile — a typical
"works on Android and iOS, breaks on desktop" mistake. A common-code single { TrailMateDatabase(get()) } in
trailMateModule then consumes the SqlDriver without knowing which one it is.
One Entry Function, Three Platforms
Each platform must start Koin exactly once, before the first dependency is requested. Put the common part in a
function and let the platform pass whatever extra it has (the Android Context, a logger).
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/KoinInit.kt
package com.example.trailmate.di
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(trailMateModule, platformModule)
}
// androidApp/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
package com.example.trailmate
import android.app.Application
import com.example.trailmate.di.initKoin
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
class TrailMateApp : Application() {
override fun onCreate() {
super.onCreate()
initKoin {
androidLogger()
androidContext(this@TrailMateApp)
}
}
}
On Android the Application is the composition root; register it in AndroidManifest.xml with
android:name=".TrailMateApp" (see App Components,
Manifest and Intents). On iOS the Kotlin function initKoin is exported under a different name: Kotlin/Native’s
Objective-C export prefixes functions that start with init to avoid clashing with the Objective-C init
constructor family, so Swift sees it as doInitKoin. Defining the function in a file called KoinInit.kt
makes Swift see it as a method of KoinInitKt.
// iosApp/iosApp/TrailMateApp.swift
import SwiftUI
import SharedLogic
@main
struct TrailMateApp: App {
init() {
// Kotlin initKoin(appDeclaration) is exported to Swift as doInitKoin.
KoinInitKt.doInitKoin(appDeclaration: { _ in })
}
var body: some Scene {
WindowGroup { TrailListView() }
}
}
With Swift export (Alpha) the names and the lambda handling differ; the framework, function names and examples here describe the Objective-C based export, which is the Stable default.
Resolving Dependencies from Swift
Swift code cannot call Kotlin’s inline reified get<T>(), so give it a Kotlin facade that is a KoinComponent.
The facade is the only place in the iOS layer that talks to the container, which keeps Swift free of the service
locator.
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/di/TrailMateDependencies.kt
package com.example.trailmate.di
import com.example.trailmate.presentation.TrailDetailViewModel
import com.example.trailmate.presentation.TrailListViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
object TrailMateDependencies : KoinComponent {
fun trailListViewModel(): TrailListViewModel = get()
fun trailDetailViewModel(trailId: String): TrailDetailViewModel =
get { parametersOf(trailId) }
}
// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic
struct TrailListView: View {
// The shared ViewModel is created by Koin and kept alive by the view.
@StateObject private var holder = ViewModelHolder(TrailMateDependencies.shared.trailListViewModel())
var body: some View {
List(holder.trails, id: \.id) { trail in
Text(trail.name)
}
}
}
How ViewModelHolder observes the StateFlow<TrailListUiState> (through SKIE or KMP-NativeCoroutines) is covered
on Architecture, Shared ViewModels and State
and the iOS interop pages. A ViewModel handed to Swift this way is not cleared automatically: call its clear()
equivalent (or use the ViewModelStoreOwner pattern from the architecture page) when the screen goes away.
Koin in Compose Multiplatform
When the UI is shared, Compose can host the container itself. KoinApplication (from koin-compose) starts Koin
and provides it to the composition, and koinViewModel() (from koin-compose-viewmodel) resolves a ViewModel
declared with viewModel/viewModelOf and ties it to the composition’s ViewModelStoreOwner.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/App.kt
package com.example.trailmate.ui
import androidx.compose.runtime.Composable
import com.example.trailmate.di.platformModule
import com.example.trailmate.di.trailMateModule
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun App() {
KoinApplication(application = {
modules(trailMateModule, platformModule)
}) {
TrailMateTheme {
TrailListScreen(viewModel = koinViewModel())
}
}
}
@Composable
fun TrailListScreen(viewModel: TrailListViewModel = koinViewModel()) {
// collect viewModel.uiState: StateFlow<TrailListUiState> ...
}
There are two ways to start Koin and you should pick one: either startKoin in the platform entry point
(Android Application, Swift App.init) or KoinApplication inside App(). Doing both fails with a
"KoinApplication has already been started" error. KoinApplication is the simpler choice for fully shared Compose
UI, because desktop, web and the iOS MainViewController all call the same App(); but a platform module that needs
the Android Context then needs KoinApplication to receive it (androidContext through the
application lambda on Android only). The startKoin route is better when native SwiftUI screens and shared
Compose screens must share one container.
koinViewModel() lives in org.koin.compose.viewmodel and relies on the multiplatform Jetpack
lifecycle-viewmodel-compose port; the older org.koin.androidx.compose.koinViewModel import is Android only
and does not compile in commonMain. Passing a navigation argument works with koinViewModel(parameters = \{
parametersOf(trailId) }), matching the viewModel { params → … } definition above.
Testing the Graph with verify()
Because Koin resolves at runtime, a missing binding crashes only when the screen is first opened. verify() from
koin-test closes that gap: it walks every definition’s constructor and checks that each parameter type is defined
in the module, without instantiating anything. It relies on reflection, so it runs on the JVM (an Android host
test or the desktop target), not on iOS or web.
// sharedLogic/src/androidHostTest/kotlin/com/example/trailmate/di/KoinModulesTest.kt
package com.example.trailmate.di
import android.content.Context
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import kotlin.test.Test
import org.koin.test.verify.verify
class KoinModulesTest {
@Test
fun trailMateModuleIsComplete() {
// Types that the module needs but that the platform module supplies at runtime.
trailMateModule.verify(
extraTypes = listOf(HttpClient::class, Context::class, LocationProvider::class),
)
}
}
extraTypes lists dependencies that are legitimately provided elsewhere (the platform module, androidContext(),
or injected parameters). The test fails when someone adds class TrailListViewModel(val analytics: Analytics) and
forgets to register Analytics. Pair it with a ViewModel test that supplies a fake through koinTestRule or by
passing the fake directly: because constructor injection is plain Kotlin, most unit tests (for example
TrailListViewModelTest with Turbine and a Mokkery or hand-written FakeTrailRepository) need no container at all.
Compile-Time Alternatives: Metro and kotlin-inject
Runtime resolution is Koin’s trade-off. If you want the graph validated by the compiler, with no registry lookups and no reflection, use a compile-time framework. Both of the following are third-party and multiplatform.
Metro
Metro (dev.zacsweers.metro, by Zac Sweers, 1.4.5 at the baseline) is a Kotlin compiler plugin. It
combines ideas from Dagger, Anvil and kotlin-inject: you write annotations such as @Inject, @Provides and
@DependencyGraph, and the plugin generates the wiring during compilation, reporting a missing binding as a
build error with a readable trace. Because it is a compiler plugin rather than an annotation processor, it needs no
KSP or kapt and adds little build time. It is a young, third-party project: pin its version and check its
compatibility with your Kotlin version on every upgrade, since compiler plugins depend on compiler internals.
Apply the plugin with id("dev.zacsweers.metro") version "1.4.5" in the shared module.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/TrailMateGraph.kt
package com.example.trailmate.di
import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailRepository
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
class OfflineFirstTrailRepository(/* api, database */) : TrailRepository
@DependencyGraph(AppScope::class)
interface TrailMateGraph {
val trailRepository: TrailRepository
@Provides
@SingleIn(AppScope::class)
fun provideHttpClient(): HttpClient = HttpClient()
}
Obtain the graph with createGraph<TrailMateGraph>() from dev.zacsweers.metro and keep it in one holder.
The mapping to Koin is direct: @Inject on a class replaces singleOf, @Provides replaces a lambda
definition, @SingleIn(AppScope::class) replaces single, and @ContributesBinding replaces bind.
Platform-specific objects enter through a graph factory that takes them as parameters
(@DependencyGraph.Factory) — the platform entry point creates the graph and passes the Context or the
LocationProvider. The exact annotation names and the ViewModel integration (multibindings into a map) evolve
between releases; treat the snippet as the shape, not a copy-paste, and read the Metro documentation for your
version. Metro is also the framework the Android page
mentions as its forward-looking option.
kotlin-inject
kotlin-inject (me.tatarka.inject, by Evan Tatarka, 0.9.0 at the baseline, still pre-1.0) is a KSP-based
compile-time DI library modelled on Dagger but designed for Kotlin and multiplatform. Components are abstract
classes; the processor generates the implementation, and create() extension functions are generated per target.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/AppComponent.kt
package com.example.trailmate.di
import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailRepository
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import me.tatarka.inject.annotations.Scope
@Scope
annotation class AppScope
@AppScope
@Component
abstract class AppComponent {
abstract val trailRepository: TrailRepository
@Provides
@AppScope
protected fun repository(impl: OfflineFirstTrailRepository): TrailRepository = impl
}
Gradle needs the KSP plugin and one ksp configuration per target (kspCommonMainMetadata, kspAndroid,
kspIosArm64, kspIosSimulatorArm64, and so on), as described on
Dependencies, klibs.io, cinterop and
SwiftPM Import. That per-target KSP setup is the main practical cost compared with Metro’s compiler plugin.
Comparing the Options
| Manual DI | Koin 4.2 | Metro | kotlin-inject | |
|---|---|---|---|---|
Resolution |
Compile time (plain Kotlin) |
Runtime (registry of lambdas) |
Compile time (compiler plugin) |
Compile time (KSP) |
Missing binding |
Compile error |
Runtime crash, unless |
Build error with a trace |
Build error |
Setup effort |
None |
Small: one dependency per layer |
Small: one Gradle plugin |
Medium: KSP per target |
Android lifecycle integration |
By hand |
Built in ( |
Via your own glue |
Via your own glue |
Compose Multiplatform |
By hand ( |
|
By hand or community glue |
By hand |
Swift access |
Expose the container object |
|
Expose the graph object |
Expose the component object |
Maturity |
Always available |
Widest KMP adoption and documentation |
Young but active, 1.x |
Pre-1.0, stable in practice |
Good fit |
Small apps, libraries, learning |
Most apps, fastest start |
Larger graphs, compile-time guarantees |
Teams already used to Dagger-style code |
Which to choose: start with manual DI if the graph has fewer than about twenty bindings; choose Koin when
you want the lowest-friction, best-documented route and accept runtime resolution backed by a verify() test; choose
Metro when the graph is large or the team wants Dagger-like safety without KSP; choose kotlin-inject mostly
when it is already in the codebase. Libraries that other apps consume should avoid imposing a container at all:
accept dependencies as constructor parameters and let the app wire them.
Common Mistakes
-
Starting Koin twice.
startKoinin the AndroidApplicationandKoinApplicationinApp()both succeed separately and crash together. Pick one entry point per process. -
Forgetting an
actual platformModuleon one target. The shared module list compiles on Android and iOS but desktop or web fails; rememberdesktopMainandwasmJsMain. -
Calling
get()orinject()from business classes. That is a service locator: dependencies become invisible, and tests need a running container. Use constructor parameters. -
Using
singlefor ViewModels. A singleton ViewModel outlives its screen and keeps stale state; useviewModel/viewModelOf. -
Importing
org.koin.androidx.compose.koinViewModelincommonMain. Useorg.koin.compose.viewmodel.koinViewModel. -
Expecting Swift to see default arguments or
reifiedfunctions. Add explicit overloads or a facade object. -
Not exporting Koin to the iOS framework while calling
KoinComponenttypes from Swift: declarekoin-corewithapiandexportit in the framework configuration. -
Skipping
verify(). Without it a typo in a module shows up as a crash on a user’s device.
Summary
Hilt and Dagger stay on Android; shared code is wired with manual constructor injection or a multiplatform
framework. Koin 4.2 gives the shortest path (trailMateModule in commonMain, an expected platformModule,
startKoin from the Android Application or KoinApplication in App(), doInitKoin from Swift, and a JVM
verify() test), while Metro and kotlin-inject move the graph check to compile time at the cost of annotations and
build setup. Whichever you choose, keep classes plain with constructor parameters so the framework stays at the
edge. The next topic, Essential Multiplatform
Libraries, surveys what to put into those modules.