Why Koin Won the KMP Dependency Injection Battle
Dependency Injection in Kotlin Multiplatform with Koin Part 1 of 8When I started building production Kotlin Multiplatform applications three years ago, choosing a dependency injection framework felt like picking sides in a religious war. Dagger developers swore by compile-time safety. Koin advocates praised simplicity. Kodein users quietly did their thing in the corner.
But KMP changed the equation entirely. The question wasn't "which DI framework is best?"βit was "which DI framework actually works across platforms?"
This article explores why Koin emerged as the pragmatic choice for KMP projects, what trade-offs you're accepting, and how to decide if it's right for your team.
The Scenario: Building Budget Tracker
Throughout this series, we'll follow the development of Budget Trackerβa fictional personal finance app we'll use to illustrate Koin patterns in Kotlin Multiplatform. The app concept: users can connect bank accounts, track transactions, set budgets, and receive spending alerts.
Here's our hypothetical team composition:
- 3 Android developers with Dagger/Hilt experience
- 2 iOS developers who've never touched Kotlin
- 1 backend developer helping with shared business logic
- Deadline: 4 months to MVP
The mandate from leadership: maximize code sharing between platforms. The business logic for budget calculations, transaction categorization, and sync algorithms should be written once and shared.
This is a common scenario many teams face. And it's where most teams hit their first major KMP decision: how do you wire up dependencies in code that runs on fundamentally different runtimes?
Note: Budget Tracker is a fictional example app created for this article series. All code samples, team scenarios, and implementation details are illustrative and designed to demonstrate Koin patterns in KMP development.
What Makes DI in KMP Genuinely Different
Dependency injection on a single platform is a solved problem. On Android, Dagger/Hilt has dominated for years. On iOS, the community leans toward manual injection or lightweight containers. On backend JVM, Spring's DI is ubiquitous.
But KMP breaks the assumptions these frameworks were built on.
The Two-Runtime Reality
When you write KMP code, you're not writing code that runs in one place. You're writing code that compiles to:
- JVM bytecode for Android, running on ART with garbage collection
- Native binaries for iOS, running with ARC (Automatic Reference Counting)
- Optionally, JavaScript for web targets
Each runtime has different memory models, threading rules, and lifecycle semantics. A singleton on Android lives until the process dies. A singleton on iOS might be deallocated if nothing holds a strong reference to it.
Consider this architecture diagram:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Shared KMP Code β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Business Logic, Repositories, Use Cases, ViewModels β β
β β (Written once in Kotlin) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
Compiles to JVM Compiles to Native
β β
βΌ βΌ
βββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β Android App β β iOS App β
β β’ Garbage Collection β β β’ ARC Memory Management β
β β’ Activity/Fragment Life β β β’ UIKit/SwiftUI Lifecycle β
β β’ Context-dependent APIs β β β’ No Context Concept β
β β’ Dagger works natively β β β’ No annotation processing β
βββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββYour DI solution needs to work correctly in both worlds. Not "sort of work" or "work with workarounds"βactually work, reliably, in production.
The Annotation Processing Wall
Here's where Dagger hits a wall. Dagger's power comes from annotation processing: you annotate your classes, and at compile time, Dagger generates the wiring code. This approach provides compile-time verification that your dependency graph is complete.
But annotation processing is a JVM concept. When the Kotlin compiler targets iOS (Kotlin/Native), there's no annotation processor running. The generated Dagger code simply doesn't exist on the iOS side.
Some teams try a hybrid approach: use Dagger on Android, manually wire dependencies on iOS. This creates two parallel dependency graphs that must stay in sync. When your Android developer adds a new dependency to the Dagger module, the iOS developer must remember to add the same dependency manually. In Budget Tracker's case, with a team of 5 developers moving fast, this synchronization would inevitably break.
The Threading Surprise
Early KMP adopters learned a painful lesson about threading. The original Kotlin/Native memory model required objects shared between threads to be "frozen"βmade immutable. This meant singletons used across threads needed special handling.
The good news: Kotlin 2.0 introduced a new memory model that eliminates these restrictions. The bad news: some DI patterns written for the old model are now unnecessarily complex.
Any DI solution you choose should be designed for modern Kotlin, not carrying legacy patterns from the frozen-object era.
Comparing Your Options
Let's honestly evaluate the DI options available for KMP in 2025.
Dagger/Hilt: The Android Champion
What it offers: Compile-time dependency graph verification, excellent Android integration, massive community, proven at scale (used by Google internally).
The KMP problem: Doesn't work on iOS. Period. You can use Dagger for your Android-specific code, but it can't touch your shared KMP modules.
When it still makes sense: If you have a large existing Android codebase and you're adding KMP incrementally just for business logic, keeping Dagger for Android-specific wiring while using something else for shared code is viable. But you're maintaining two systems.
kotlin-inject: The Pure Kotlin Contender
What it offers: Compile-time verification using KSP (Kotlin Symbol Processing), which does support multiplatform. Feels similar to Dagger but built for Kotlin.
The KMP reality: KSP multiplatform support has matured significantly. kotlin-inject can now generate code for all KMP targets. It's a legitimate option if compile-time safety is your top priority.
The trade-off: Smaller community, less documentation, fewer battle-tested patterns. You'll be figuring things out that Koin users solved years ago. Map multibindings use a different approach than Dagger (pairs instead of map keys).
Metro: The Compiler Plugin Newcomer
What it offers: Compile-time dependency graph verification implemented as a Kotlin compiler plugin (not KSP). Combines the best of Dagger, Anvil, and kotlin-injectβcompile-time safety with Anvil-style aggregation (@ContributesTo, @ContributesBinding) built-in. Multiplatform support from day one.
The KMP reality: Metro generates code directly in the compiler's IR phase, which means faster builds than KSP-based solutions and full multiplatform support. It validates your entire dependency graph at compile time, catching missing bindings before runtime.
The trade-offs: Newer framework (started 2024), so smaller community and less documentation compared to Koin. Requires Kotlin 2.2+ for full feature support. Being a compiler plugin, IDE support requires enabling experimental K2 features. Some advanced features like cross-module aggregation on non-JVM targets require Kotlin 2.3.20+.
When it shines: If you want Dagger-style compile-time safety with Anvil-style aggregation in a true multiplatform environment, Metro is worth serious consideration. Build performance benchmarks show it significantly outperforms Dagger+KSP for incremental builds.
Kodein: The Veteran
What it offers: Been around since early Kotlin days, supports all KMP targets, type-safe DSL.
The perception problem: Kodein works fine, but its syntax feels verbose compared to Koin. The community has largely consolidated around Koin, meaning fewer blog posts, Stack Overflow answers, and library integrations.
When it fits: If your team already uses Kodein and is happy with it, there's no compelling reason to switch.
Koin: The Pragmatic Choice
What it offers: Pure Kotlin DSL with no code generation, works identically on all KMP targets, large community, extensive documentation, actively maintained.
The trade-off: Runtime dependency resolution instead of compile-time verification. If you forget to provide a dependency, you'll find out at runtime, not compile time.
Why teams choose it: It just works. The same code runs on Android, iOS, and desktop without modification. The learning curve is gentle. The debugging experience is straightforward.
Here's a decision matrix based on team composition:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DI Framework Decision Matrix β
ββββββββββββββββββββββββ¬ββββββββββββ¬ββββββββββββββ¬ββββββββββ¬ββββββββββββββ¬βββββββββββββββββ€
β Scenario β Dagger β kotlin- β Metro β Kodein β Koin β
β β β inject β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Android-only team β β
Best β β
Good β β
Good β β οΈ OK β β
Good β
β adding KMP β β β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Mixed Android/iOS β β Avoid β β
Good β β
Best β β
Good β β
Best β
β team, new project β β β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Compile-time safety β β N/A β β
Best β β
Best β β οΈ OK β β οΈ verify() β
β is top priority β for KMP β β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Build performance β β οΈ Slow β β οΈ Medium β β
Best β β
Good β β
Good β
β is critical β (KAPT/KSP)β (KSP) β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Team new to DI β β Complexβ β οΈ Medium β β οΈ Mediumβ β οΈ OK β β
Best β
β concepts β β β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Need extensive β β
Yes β β οΈ Growing β β οΈ New β β οΈ Some β β
Yes β
β community support β β β β β β
ββββββββββββββββββββββββΌββββββββββββΌββββββββββββββΌββββββββββΌββββββββββββββΌβββββββββββββββββ€
β Anvil-style β β οΈ Anvil β β οΈ kotlin- β β
Builtβ β No β β οΈ Manual β
β aggregation β addon β inject-anvilβ -in β β β
ββββββββββββββββββββββββ΄ββββββββββββ΄ββββββββββββββ΄ββββββββββ΄ββββββββββββββ΄βββββββββββββββββFor the Budget Tracker teamβmixed Android/iOS developers starting a new KMP projectβKoin remains a strong recommendation due to its mature ecosystem and gentle learning curve. However, if compile-time safety and build performance are priorities, Metro is increasingly worth considering as the ecosystem matures.
The Compile-Time Safety Debate
Let's address the elephant in the room: Koin's runtime dependency resolution.
If you come from Dagger, the idea of discovering a missing dependency at runtime feels like regression. Dagger's compile-time graph validation catches entire categories of bugs before your code ever runs. And now with Metro offering similar compile-time guarantees with full KMP support, the question becomes more nuanced.
Here's an honest assessment of what you're gaining and losing with Koin's runtime approach.
What You Lose
Immediate feedback on missing dependencies. With Dagger, if you forget to provide a UserRepository, compilation fails. With Koin, the app compiles fine but crashes when something tries to inject UserRepository.
IDE support for dependency navigation. Dagger's generated code lets your IDE understand the dependency graph. You can click through from an injection site to see where the dependency is provided. Koin's DSL is less transparent to IDEs.
Guaranteed acyclic graphs. Dagger verifies at compile time that your dependency graph has no cycles. Koin will let you create circular dependencies that cause stack overflows at runtime.
What You Gain
Platform parity. The exact same dependency configuration runs on Android, iOS, desktop, and web. No platform-specific wiring code to maintain.
Simpler mental model. Koin modules are just Kotlin code. There's no generated code to understand, no component hierarchies to manage, no scope annotations to memorize.
Faster iteration. No annotation processing means faster builds. In large projects, Dagger's code generation can add significant time to incremental builds.
Easier debugging. When something goes wrong, you're debugging Kotlin code you wrote, not generated code you've never seen.
The Mitigation: Koin's verify() API
Koin isn't blind to the compile-time safety concern. Version 4.0 introduced the verify() API that validates your module configuration:
class KoinModulesTest {
@Test
fun `verify all modules are correctly configured`() {
appModule.verify()
}
}This test runs in your CI pipeline and catches missing dependencies before they reach production. It's not compile-time verification, but it's compile-time-adjacent: if your tests pass, your modules are valid.
The verify() API supports declaring external dependencies that are provided at runtime:
@Test
fun `verify modules with platform dependencies`() {
appModule.verify(
extraTypes = listOf(
PlatformDependencies::class,
Application::class
)
)
}In a typical setup, you would run module verification tests on every pull request. This catches missing dependency bugs in CI that would otherwise be runtime crashes.
Is it as good as Dagger's or Metro's compile-time verification? No. Is it good enough for production apps? For many teams, yes. If you need true compile-time guarantees with multiplatform support, Metro is worth evaluatingβit validates your entire dependency graph during compilation, not as a separate test step.
When NOT to Use Koin
Koin isn't always the right choice. Here are scenarios where you should consider alternatives:
Large Android-Only Codebase with Existing Dagger
If you have a mature Android app with hundreds of Dagger modules and you're adding KMP for a few shared utilities, don't rip out Dagger. Use Koin for the shared KMP code and bridge to your existing Dagger setup on Android.
Compile-Time Safety Is Non-Negotiable
Some teams, particularly in regulated industries (healthcare, finance), have strict requirements for static analysis. If your organization mandates compile-time dependency verification, evaluate Metro or kotlin-inject instead. Metro is particularly compelling hereβit offers Dagger-style validation with better build performance and built-in aggregation support.
Team Strongly Prefers Dagger Patterns
If your team loves Dagger's @Component, @Module, @Inject pattern and finds Koin's DSL unfamiliar, forcing a switch will create friction. Metro offers the closest experience to Dagger with annotations like @DependencyGraph, @Provides, @Inject, and Anvil-style @ContributesTo/@ContributesBinding. kotlin-inject is another middle ground with similar annotations but KMP support.
You're Building a Library, Not an App
Libraries shouldn't dictate DI frameworks to their consumers. If you're building a KMP library for public consumption, use manual dependency injection (constructor parameters) and let consumers wire dependencies however they prefer.
How Koin Works: The 10,000-Foot View
Before diving into implementation in the next article, let's establish a mental model of how Koin operates.
At its core, Koin maintains a registry of "how to create things." When you define a module, you're registering factory functions:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Koin's Mental Model β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Module Definition (you write this): β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β "When someone asks for UserRepository, β β
β β create a UserRepositoryImpl with these dependencies" β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β Koin Registry (internal): β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β UserRepository β { UserRepositoryImpl(get(), get()) } β β
β β ApiService β { ApiServiceImpl(get()) } β β
β β HttpClient β { HttpClient(engine = get()) } β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β Resolution (at runtime): β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 1. App asks for UserRepository β β
β β 2. Koin finds the factory function β β
β β 3. Factory needs ApiService β Koin resolves that first β β
β β 4. ApiService needs HttpClient β Koin resolves that β β
β β 5. Build chain resolves, UserRepository returned β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe key insight: Koin doesn't create anything until you ask for it. When you call get<UserRepository>(), Koin walks the dependency tree, creating instances as needed. If UserRepository was declared as a singleton, Koin caches the instance and returns the same one on subsequent calls.
This lazy resolution is what enables platform parity. The same factory functions run on JVM and Native without modificationβthey're just Kotlin lambdas.
Budget Tracker's Decision
For our fictional Budget Tracker project, the team would choose Koin based on these factors:
Mixed team composition: iOS developers wouldn't want to learn Dagger concepts that don't apply to iOS.
Time constraints: A tight MVP deadline means choosing proven, well-documented tools over cutting-edge options.
Acceptable risk: Runtime dependency errors, mitigated by
verify()tests, are an acceptable trade-off for platform simplicity.Community support: When teams hit issues, Stack Overflow and Koin's Slack typically have answers.
Your team's calculus might be different. The important thing is making an informed decision rather than defaulting to what you know from single-platform development.
What's Next
In the next article, we'll set up Koin in Budget Tracker's codebase. We'll cover:
- Project structure and module organization patterns that scale
- The platform module pattern for Android and iOS differences
- Named qualifiers for multiple instances of the same type
- Common setup mistakes and how to avoid them
We'll write real code, but more importantly, we'll understand why each pattern exists and what problems it solves.