Why Koin Won the KMP Dependency Injection Battle

πŸ“š Dependency Injection in Kotlin Multiplatform with Koin Part 1 of 8

When 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:

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:

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:

  1. Mixed team composition: iOS developers wouldn't want to learn Dagger concepts that don't apply to iOS.

  2. Time constraints: A tight MVP deadline means choosing proven, well-documented tools over cutting-edge options.

  3. Acceptable risk: Runtime dependency errors, mitigated by verify() tests, are an acceptable trade-off for platform simplicity.

  4. 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:

We'll write real code, but more importantly, we'll understand why each pattern exists and what problems it solves.