A DI module that compiles on every target and only works on one
Registration is common. The bindings are not. DI defers the error to first use, which is usually on a user's device.
TL;DR
A dependency-injection container turns a compile-time question — is this type available? — into a runtime one. On a single platform that trade buys you flexibility. Across platforms it buys you a build that passes while one target is missing a binding entirely.
The compiler cannot help, because a Koin module is a lambda that registers factories, not a
declaration the type system checks. Nothing in module { … } is resolved until someone asks for
it, and "someone asks for it" happens on a device.
The fix is not to abandon DI. It is to make the graph assertable, and to assert it per target in
commonTest.
The case this post is about
A shared feature module with the ordinary layering: a repository over a Store, a platform bridge for secure storage, a ViewModel on top.
// commonMain
val featureModule = module {
single<TokenStorage> { get() } // provided per platform
single { ReadingRepository(get(), get()) }
factory { ReadingViewModel(get()) }
}
// androidMain
val platformModule = module {
single<TokenStorage> { EncryptedPrefsStorage(androidContext()) }
single<CoroutineDispatcher> { Dispatchers.IO }
}
// iosMain
val platformModule = module {
single<TokenStorage> { KeychainStorage() }
// CoroutineDispatcher — never registered. Nobody noticed.
}
ReadingRepository takes a dispatcher. Android registers one; iOS does not. Both targets compile,
both link, and ./gradlew build is green on all of them.
The symptom
The app launches on iOS, the user navigates to the reading list, and:
kotlin.IllegalStateException:
NoDefinitionFoundException: No definition found for type
'kotlinx.coroutines.CoroutineDispatcher'. Check your Modules configuration.
at org.koin.core.scope.Scope.get
at com.example.ReadingRepository$special$$inlined$get$1
at ReadingViewModel.<init>
Three properties of that crash are worth naming, because together they are why this class of bug survives to production:
It fired on navigation, not at startup — the ViewModel is a factory, so the resolution that
fails happens the first time the screen is opened. Startup smoke tests pass.
It names a type, not a call site. CoroutineDispatcher is requested in several places; the
message tells you what was missing, not which graph was incomplete.
It is platform-specific and absent from the build log. The Android build, the Android tests, and the Android run were all evidence that the module was fine.
Why the obvious fix failed
"Register the dispatcher in iosMain." Correct, and it fixes this one. It fixes nothing about the next binding someone forgets, which is the actual problem — one module gained a parameter and one platform's module was not updated.
"Make commonMain register the dispatcher." Tempting, and it is wrong for the reason the
dispatcher was platform-specific to begin with: there is no portable Dispatchers.IO. Moving a
binding to common to avoid forgetting it only works when the value is genuinely common.
"Use constructor injection and let the compiler check it." This is the real answer for some of the graph, and I over-applied it at first. Constructor-injecting everything into a root factory does make the compiler check it — and you have then hand-written the container, with manual wiring of every scope and lifetime. Worth it at the edges; not worth it for forty bindings.
"Koin has a checkModules verifier." It does, and it is the right tool — but running it only
in androidUnitTest, which is where tests already lived, verifies the one graph that was never
broken. The verifier was not missing; it was pointed at the wrong target.
The actual mechanism
A Koin module is a lambda of registrations. module { single { Foo(get()) } } stores a
factory; the get() inside it is not evaluated at registration, it is evaluated when something
asks for a Foo. That deferral is the entire feature — it is what lets you register in any order
and resolve lazily.
It also means the graph has no compile-time existence. The compiler sees a well-typed lambda that
calls a well-typed function. Whether get<CoroutineDispatcher>() can succeed is a property of a
runtime map, assembled from however many module lists that particular target happens to
install.
So three things line up against you:
Registration is common, satisfaction is per-target. commonMain declares what it needs.
Each platform assembles a different map to satisfy it. Nothing checks that the maps are complete.
Resolution is lazy and scope-dependent. single resolves at first request; factory resolves
at every request. A missing binding behind a factory deep in a navigation graph is reachable only
by the user path that reaches it.
And the sub-case that makes the usual answer only a third of an answer: Koin ships
checkModules(), which walks every registration and resolves it eagerly — exactly the
compile-time check the graph does not have. It is excellent, and it verifies the map it is
given, which is one target's map.
// commonTest — looks like it covers everything, and is per-target by construction
@Test fun graphResolves() = checkKoinModules(appModules + platformModule())
Run on the JVM only — which is the fast target, and therefore the one a local ./gradlew test
hits — it proves the Android/desktop map is complete and says nothing about iOS or wasm, whose
platformModule() is a different lambda registering different bindings. The check is sound; the
coverage is the thing that was never multiplied by the number of maps.
So the discipline is the same one commonTest demands generally: the verification has to run
wherever the thing it verifies differs. ./gradlew allTests rather than jvmTest, and in CI
rather than locally, because this is precisely the class of failure that is invisible on the
machine where you write the code and present on the device a user is holding.
Only the common half is shared. The platform modules are, by construction, four separate hand-maintained lists. Adding a constructor parameter in shared code creates an obligation on every one of them, and the only thing enforcing that obligation is memory.

The rule:
DI converts a compile-time error into a runtime one. On one platform that is a trade. On four platforms it is four runtime errors you cannot see from three of them, so the graph needs a test per target or it has no coverage at all.
The fix
Make the graph a thing that gets asserted, and assert it where every target runs.
One verification test, in commonTest. This is the whole fix; everything else is detail:
// commonTest — runs on androidUnitTest, iosTest, jvmTest, wasmJsTest
class ModuleGraphTest {
@Test
fun everyDeclarationResolves() {
startKoin {
modules(platformModule, featureModule) // platformModule = the expect
}.checkModules()
stopKoin()
}
}
checkModules() walks every registration and attempts resolution, so a missing binding fails
here instead of on a device. Because the test lives in commonTest, it runs once per target —
which is the property the old androidUnitTest-only version did not have.
Make the platform module an expect, so a target cannot simply not have one. Previously each
platform had a top-level platformModule by convention. A convention is not enforceable:
// commonMain
expect val platformModule: Module
Now a new target does not compile until it declares one. That does not check the module's
contents — checkModules does that — but it does close the gap where a target quietly has no
platform module at all.
Give parameterised bindings explicit qualifiers. The original crash was ambiguous because
CoroutineDispatcher is requested in several places. Naming them makes the failure say where:
// commonMain
val IoDispatcher = named("io")
single { ReadingRepository(get(), get(IoDispatcher)) }
// each platform
single(IoDispatcher) { Dispatchers.IO } // androidMain / jvmMain / iosMain
single(IoDispatcher) { Dispatchers.Main } // wasmJsMain — single-threaded, deliberately
A missing one now reports No definition found for type CoroutineDispatcher with qualifier 'io', which is a searchable string tied to one role rather than a type used in six.
How we proved it
The claim to test is not "iOS was missing a dispatcher" — that was already evident from the crash. It is that the test now fails for the right target before anyone ships.
So: revert the iOS binding, run the suite, and read which targets go red.
./gradlew allTests
androidUnitTest ModuleGraphTest.everyDeclarationResolves PASSED
jvmTest ModuleGraphTest.everyDeclarationResolves PASSED
iosSimulatorArm64Test ModuleGraphTest.everyDeclarationResolves FAILED
NoDefinitionFoundException: CoroutineDispatcher with qualifier 'io'
wasmJsTest ModuleGraphTest.everyDeclarationResolves FAILED
NoDefinitionFoundException: CoroutineDispatcher with qualifier 'io'
Two targets red, two green, and the two red ones are exactly the two whose platform modules were incomplete — wasm turning out to have the same hole, which nobody had found because no one had opened that screen in a browser yet. One test, run per target, surfaced a second instance of the bug we were fixing.
Restore the bindings and all four pass. That is the loop that was missing.
A limit worth stating plainly: checkModules() verifies that every registered definition can be
resolved. It does not know about a dependency nobody registered anywhere and nobody requests yet,
and it cannot evaluate bindings that need a real platform context — an Android Context, a live
Keychain — so those need a stub or an exclusion. It closes the "forgot one platform" class, which
is the class that was biting us. It is not a proof that the graph is correct.
What this generalizes to
Any container trades a compile error for a runtime one — count the cost per environment. One process, one graph: cheap. Four targets, four graphs assembled from different lists: the same trade now costs you four times and the feedback arrives from whichever one a user opened.
A test that only runs on one target is not coverage of shared code. This is the broader
multiplatform lesson and it is not specific to DI. If the test source set is androidUnitTest, it
is testing Android. Shared behaviour belongs in commonTest precisely so the target multiplies
the test rather than the test picking a target.
Convention is not enforcement; a declaration is. "Every platform has a platformModule" was
true until it wasn't. One expect val makes the same statement to the compiler, which does not
forget.
Name the role, not just the type. CoroutineDispatcher is a type used everywhere;
named("io") is a role used in one place. When a lookup fails, the one that names a role tells
you what is missing — a property worth having in error messages generally, not only in DI.
Related
- expect/actual is not an interface, and the difference shows up at link time — compile-time vs runtime resolution, from the other end
- One Ktor client, four engines, and the timeouts that don't mean the same thing — shared config, per-target enforcement
- CompositionLocal is not dependency injection — what DI is actually for
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".