Migrating dependency injection to a multiplatform container

SkillAI & models

Move dependency injection from an annotation-processed compile-time framework (Hilt/Dagger) to the multiplatform runtime container (Koin) — the mechanical mapping for providers, view models and qualifiers, what happens to assisted injection, and the two failure modes the migration introduces: a graph that no longer fails at compile time, and a module definition that blocks the thread starting the container. Use when planning the migration, when a binding resolves to nothing at runtime after it, or when app start got slower afterwards.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Migrating dependency injection to a multiplatform container skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/hilt-to-koin-migration/SKILL.md and read by ahel’s review.

The motive is reach, not ergonomics. Hilt generates Android-specific code; Dagger's processor is general JVM but is still a Java annotation processor. Neither runs against a commonMain source set compiled for JVM/desktop and iOS, so once shared modules must be injected on more than one platform a multiplatform runtime container is the only way to keep one wiring for all targets. Do not sell it internally as "less boilerplate" — you trade a compile-time-verified graph for a runtime one, which is a real cost (see the first trap).

The mechanical mapping

BeforeAfter
@Module @InstallIn(SingletonComponent::class) object Xval xModule = module { … }
@Provides @Singleton fun provideA(b: B): Asingle<A> { provideA(get()) }
@Binds fun bind(impl: AImpl): Asingle<A> { AImpl(get()) }
class C @Inject constructor(d: D)single { C(get()) } — the constructor is now written out
unscoped @Inject constructorfactory { … } — a new instance per resolve
@HiltViewModel class V @Inject constructor(…)viewModel { V(get(), get()) }
@Named("x") / a qualifier annotationnamed("x") on the definition, get(named("x")) at the use site
@Inject lateinit var field injectionimplement KoinComponent, then val a: A by inject()
@HiltAndroidApp / @AndroidEntryPointnothing — call startKoin { modules(…) } once

Constructor injection is the part that inverts. Under the annotation processor the constructor was the declaration; under the container the constructor is written by hand inside the definition, and get() fills each parameter positionally by type:

// adapted — parameter lists shortened
val repositoryModule = module {
    single<AlbumRepository> { AlbumRepositoryImpl(get(), get()) }
    single<CommonRepository> { CommonRepositoryImpl(get(named(SERVICE_SCOPE)), get(), get()) }
}

Type the definition by the interface (single<AlbumRepository>) or a consumer asking for the interface finds nothing — the implementation is likely internal anyway (clean-arch-kmp-readiness). Modules are aggregated per layer behind one shared function, with a hook for the platform parts:

// adapted — two load calls merged, platform hook renamed
fun loadAllModules() {                                  // commonMain
    loadKoinModules(listOf(databaseModule, repositoryModule, mediaHandlerModule))
    loadPlatformModules()
}
expect fun loadPlatformModules()

Assisted injection has no direct equivalent

Assisted injection existed for objects whose constructor is partly supplied by a framework — a worker handed a context and parameters at construction. The container has no direct equivalent, so the framework-supplied parameters stay in the constructor and only the injected one moves out:

// adapted — the migration route: framework parameters stay plain, the injected one moves out
class NotifyWork(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params), KoinComponent {
    private val repository: SomeRepository by inject()
}

That is a genuine downgrade: the dependency leaves the constructor, so a test must stand up a container instead of passing a fake. Where you own the call site instead, factory { (id: String) -> Presenter(id, get()) } plus get { parametersOf(someId) } avoids it — though this codebase's own factory census (Verifying it, #2) shows it always took the first route.

Traps

Nothing fails at compile time any more, and the replacement check is opt-in. The processor used to reject a missing binding before the app ran; the container discovers it at the first resolve — which may be a screen three taps deep, in a release build, on a user's device. A module-verification test (checkModules / verify(), from the container's test artifact) is the replacement, and a migration landing with no such test has quietly removed the only guarantee it had. Put it on the migration checklist and confirm it stuck — Verifying it, #1.

Anything blocking inside an eager definition runs when the container starts — if that module is loaded from inside startKoin. Only startKoin creates eager instances by default; a loadKoinModules call made after start does not, so the same definition is harmless in one arrangement and blocks start-up in the other. The aggregation snippet above is invoked from inside the startKoin { } lambda here, which is what puts this definition on the start-up path:

// adapted — cache type and provider renamed
single<DiskCache>(qualifier = named(PLAYER_CACHE), createdAtStart = true) {
    provideCache(
        cacheSize = runBlocking { get<SettingsManager>().maxCacheSize.first() },  // blocks start-up
        …
    )
}

It works, it is sometimes unavoidable when a constructor demands a plain value and the setting is a flow, and it is invisible unless you profile start-up specifically. Keep such reads countable and off the eager path — datastore-kmp-manager covers the same tension from the storage side.

Whatever must happen before the container starts has to be moved above startKoin explicitly. Eagerly-created singletons touch real resources — a database file, a lock — the instant the container starts, so a single-instance guard or a file migration that used to sit in the first screen is already too late. Ordering is the fix, and only one platform tends to expose it.

named("…") is a string, and a typo compiles. Qualifier annotations were checked; qualifier strings are not. Declare each as a constant in a module both sides depend on, and reference it.

Everything becoming single is a silent behaviour change. Unscoped bindings under the previous framework produced a new instance per injection point; mapped onto single they get process lifetime, and anything holding per-use state now shares it. Check that factory exists at all — anchored to a definition site — Verifying it, #2 explains why and shows this codebase's result.

Do not put a platform component into the container to make injection convenient. Registering an activity, a window or a service hands the container a reference that outlives it, so every consumer resolves a stale one after the first recreation. Pass it as a parameter instead. View-model wiring has its own set of these — see koin-viewmodel-scoping-traps.

Verifying it

  1. The opt-in module-verification test actually exists somewhere in the migration:

    grep -rn "checkModules\|verify()" --include="*.kt" --include="*.kts" . --exclude-dir=build
    

    Pass condition: at least one hit — none means the guarantee this migration lost at compile time was never replaced. This repository has none today: a real, currently-open gap, not a demo.

  2. factory never appears (anchored, so a comment can't fool it), and every repository binding is typed by the interface the mapping table shows, not the concrete class:

    grep -rnE '^[[:space:]]*factory(Of)?[[:space:]]*[<({]' --include="*.kt" . --exclude-dir=build
    REPO_MODULE=core/data/src/commonMain/kotlin/com/maxrave/data/di/RepositoryModule.kt   # your equivalent
    grep -c "single<[A-Za-z]*Repository>" "$REPO_MODULE"; grep -c "single {" "$REPO_MODULE"
    

    Pass condition: first grep empty — every binding here is single/viewModel, not factory — and the two counts after read 17 against 0: typed definitions only, none by concrete class.

  3. The second failure mode — a definition blocking the thread that starts the container — is countable, and none of it is new:

    grep -rn "runBlocking" --include="*.kt" $(find . -type d -name di -not -path "*/build/*")
    

    Pass condition: every hit is deliberate and already counted — here, 7, one of them the eager createdAtStart cache definition shown above. A hit outside that known count is the failure mode landing for real.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
hilt-to-koin-migration
Source
github.com/maxrave-dev/kotlin-footguns