In the previous tutorials, we created Ktor clients, SQLDelight databases, and DataStore instances by hand. We passed them manually to repositories, which we passed to ViewModels, which we passed to screens.

That works for small apps. But as your app grows, the wiring becomes painful. You need a way to automatically connect everything together.

That is what Koin does. It is a lightweight dependency injection framework for Kotlin — and it works perfectly in KMP.

What is Dependency Injection?

Dependency injection means: instead of creating objects inside a class, you pass them in from outside.

// WITHOUT dependency injection — tightly coupled
class UserRepository {
    private val api = UserApi() // creates its own dependency
    private val db = UserDatabase() // creates its own dependency
}

// WITH dependency injection — loosely coupled
class UserRepository(
    private val api: UserApi,       // received from outside
    private val db: UserDatabase    // received from outside
)

The second version is easier to test (you can pass fakes) and easier to change (swap implementations without touching the class).

Why Koin for KMP?

On Android, Hilt is the most popular DI framework. But Hilt depends on Android-specific code generation — it does not work in commonMain.

Koin is different:

HiltKoin
PlatformAndroid onlyKotlin Multiplatform
How it worksAnnotation processing + code generationPure Kotlin DSL, no code generation
SetupAnnotations (@Inject, @Module, @HiltViewModel)Kotlin functions (single, factory, module)
Compile timeSlower (code generation)Faster (no generation step)
KMP supportNoYes — full support

Koin uses a simple DSL to define how objects are created. No annotations, no generated code, no magic.

Setup

Dependencies

# gradle/libs.versions.toml
[versions]
koin = "4.1.0"

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
// shared/build.gradle.kts
sourceSets {
    commonMain.dependencies {
        implementation(libs.koin.core)
    }
    androidMain.dependencies {
        implementation(libs.koin.android)
    }
}

// composeApp/build.gradle.kts (or your UI module)
dependencies {
    implementation(libs.koin.compose)
    implementation(libs.koin.compose.viewmodel)
}

koin-core goes in commonMain — it has no platform dependencies. Android gets extra helpers for Context and Android-specific features.

Step 1: Define a Module

A Koin module tells the framework how to create your objects:

// shared/src/commonMain/kotlin/di/AppModule.kt

import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

val appModule = module {
    // single = one instance, reused everywhere (singleton)
    single { createHttpClient() }
    single { createDatabase() }
    single { createDataStore() }

    // factory = new instance every time
    factory { UserRepository(get(), get()) }
    factory { NoteRepository(get(), get()) }
}

Two key concepts:

  • single { } — creates the object once, returns the same instance every time. Use for expensive objects like HTTP clients and databases.
  • factory { } — creates a new object every time you ask for it. Use for lightweight objects like repositories.
  • get() — tells Koin to find and inject the dependency automatically.

Shorter Syntax with Constructor DSL

If your class takes all dependencies through the constructor, use the shorthand:

val appModule = module {
    single { createHttpClient() }
    single { createDatabase() }

    // These are equivalent:
    factory { UserRepository(get(), get()) }
    factoryOf(::UserRepository)  // shorter — auto-resolves constructor params
}

singleOf and factoryOf use Kotlin reflection to find constructor parameters. Less code, same result.

Step 2: ViewModel Module

ViewModels need their own definitions:

// shared/src/commonMain/kotlin/di/ViewModelModule.kt

import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module

val viewModelModule = module {
    viewModelOf(::UserListViewModel)
    viewModelOf(::NoteListViewModel)
    viewModelOf(::SettingsViewModel)
}

viewModelOf creates ViewModels with proper lifecycle management. On Android, they survive configuration changes. On iOS, they are created fresh.

Step 3: Platform-Specific Modules

Some dependencies need platform-specific implementations. For example, createDatabase() needs an Android Context but nothing on iOS.

// shared/src/commonMain/kotlin/di/PlatformModule.kt

import org.koin.core.module.Module

// Each platform provides its own module
expect val platformModule: Module
// shared/src/androidMain/kotlin/di/PlatformModule.android.kt

import org.koin.dsl.module

actual val platformModule = module {
    // Android needs Context for database and DataStore
    single { createDatabase(get()) }      // get() resolves Context
    single { createDataStore(get()) }     // get() resolves Context
}
// shared/src/iosMain/kotlin/di/PlatformModule.ios.kt

import org.koin.dsl.module

actual val platformModule = module {
    // iOS does not need Context
    single { createDatabase() }
    single { createDataStore() }
}

The expect/actual pattern lets each platform provide what it needs, while the rest of your DI graph stays shared.

Step 4: Start Koin

Android

// composeApp/src/androidMain/kotlin/App.kt

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@MyApplication)
            modules(
                appModule,
                viewModelModule,
                platformModule
            )
        }
    }
}

Do not forget to register MyApplication in AndroidManifest.xml:

<application
    android:name=".MyApplication"
    ... >

iOS

// shared/src/iosMain/kotlin/di/KoinInit.kt

import org.koin.core.context.startKoin

fun initKoin() {
    startKoin {
        modules(
            appModule,
            viewModelModule,
            platformModule
        )
    }
}

Call this from your Swift code:

// iosApp/iOSApp.swift
import shared

@main
struct iOSApp: App {
    init() {
        KoinInitKt.initKoin()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Step 5: Inject in Compose UI

Use koinViewModel() to get ViewModels in your Composables:

import org.koin.compose.viewmodel.koinViewModel

@Composable
fun UserListScreen(
    viewModel: UserListViewModel = koinViewModel()
) {
    val users by viewModel.users.collectAsStateWithLifecycle()

    LazyColumn {
        items(users) { user ->
            Text(user.name)
        }
    }
}

Koin finds the UserListViewModel definition, creates it with all dependencies injected, and returns it. No manual wiring.

For non-ViewModel dependencies, use koinInject():

@Composable
fun SomeScreen() {
    val settings: SettingsRepository = koinInject()
    // ...
}

Full Example: Putting It All Together

Here is a complete DI setup for an app with networking, database, and settings:

// shared/src/commonMain/kotlin/di/AppModule.kt

val networkModule = module {
    single {
        HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true
                    prettyPrint = true
                })
            }
            install(Logging) {
                level = LogLevel.BODY
            }
        }
    }
    single { UserApi(get()) }
    single { NoteApi(get()) }
}

val repositoryModule = module {
    factory { UserRepository(api = get(), db = get()) }
    factory { NoteRepository(api = get(), db = get()) }
    factory { SettingsRepository(dataStore = get()) }
}

val viewModelModule = module {
    viewModelOf(::UserListViewModel)
    viewModelOf(::NoteListViewModel)
    viewModelOf(::SettingsViewModel)
}

// Collect all modules
val allModules = listOf(
    networkModule,
    repositoryModule,
    viewModelModule,
    platformModule
)

Then start Koin with modules(allModules).

Koin vs Manual DI

Before Koin:

// Manual wiring — gets messy fast
val httpClient = createHttpClient()
val database = createDatabase(context)
val dataStore = createDataStore(context)
val userApi = UserApi(httpClient)
val noteApi = NoteApi(httpClient)
val userRepo = UserRepository(userApi, database)
val noteRepo = NoteRepository(noteApi, database)
val settingsRepo = SettingsRepository(dataStore)
val userViewModel = UserListViewModel(userRepo)
val noteViewModel = NoteListViewModel(noteRepo)
val settingsViewModel = SettingsViewModel(settingsRepo)

After Koin:

// Koin handles the wiring
startKoin {
    modules(allModules)
}

// In Compose — just ask for what you need
val viewModel: UserListViewModel = koinViewModel()

Same result, much less boilerplate. And when you add a new dependency to UserRepository, you only change the module definition — not every place that creates it.

Common Mistakes

Mistake 1: Using single for Everything

// BAD — repositories as singletons hold stale data
val appModule = module {
    single { UserRepository(get(), get()) }
}

// GOOD — factory creates fresh instances
val appModule = module {
    factory { UserRepository(get(), get()) }
}

Use single for expensive objects (HTTP client, database). Use factory for repositories and use cases.

Mistake 2: Forgetting Platform Module

// BAD — crashes because database needs Context on Android
val appModule = module {
    single { createDatabase() } // Where is the Context?
}

// GOOD — platform module handles it
expect val platformModule: Module

Mistake 3: Starting Koin Twice

// BAD — crashes on restart
startKoin { modules(appModule) }
startKoin { modules(appModule) } // Error: Koin already started

// GOOD — check or use KoinApplication in Compose

If you use KoinApplication composable, Koin handles the lifecycle for you.

Quick Reference

ActionCode
Define singletonsingle { MyClass(get()) }
Define factoryfactory { MyClass(get()) }
Define ViewModelviewModelOf(::MyViewModel)
Constructor shorthandsingleOf(::MyClass)
Get in ComposekoinViewModel<MyViewModel>()
Get non-ViewModelkoinInject<MyClass>()
Platform moduleexpect val platformModule: Module
Start on AndroidstartKoin { androidContext(app); modules(...) }
Start on iOSstartKoin { modules(...) }

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about shared ViewModels — writing ViewModel logic once in commonMain and connecting it to both Compose on Android and SwiftUI on iOS.

See you there.