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.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
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.
Migrating dependency injection to a multiplatform container
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
Before
After
@Module @InstallIn(SingletonComponent::class) object X
val xModule = module { … }
@Provides @Singleton fun provideA(b: B): A
single<A> { provideA(get()) }
@Binds fun bind(impl: AImpl): A
single<A> { AImpl(get()) }
class C @Inject constructor(d: D)
single { C(get()) } — the constructor is now written out
unscoped
@Inject constructor
factory { … } — a new instance per resolve
@HiltViewModel class V @Inject constructor(…)
viewModel { V(get(), get()) }
@Named("x") / a qualifier annotation
named("x") on the definition, get(named("x")) at the use site
@Inject lateinit var field injection
implement KoinComponent, then val a: A by inject()
@HiltAndroidApp / @AndroidEntryPoint
nothing — 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:
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:
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 outclassNotifyWork(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params), KoinComponent {
privateval 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:
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
The opt-in module-verification test actually exists somewhere in the migration:
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.
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:
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.
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.