Engineering

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.

MobileByteSensei 8 min read
kmpdiarchitecture

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.

A Koin module registering factories whose get calls are not evaluated until first request: the common module declares what it needs, each platform assembles its own runtime map to satisfy it, the compiler sees only a well-typed lambda, and the missing iOS dispatcher binding surfaces as a crash when the user opens a screen

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.

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".

Follow by RSS