Setting Up Koin Right: Module Organization for Real Apps
Dependency Injection in Kotlin Multiplatform with Koin Part 2 of 8In the previous article, we explored why Koin emerged as the pragmatic DI choice for Kotlin Multiplatform. Now it's time to write code.
But before we type startKoin, we need to answer questions that will shape our entire codebase: Where do Koin modules live? How do we handle Android's Context dependency? What happens when we need three different HTTP clients?
Getting these foundations right prevents painful refactoring later. Many teams learn this the hard way when an initial "just get it working" approach creates a monolithic module that becomes difficult to maintain.
Budget Tracker's Dependency Map
Before writing any Koin code, let's map out what our fictional Budget Tracker app would need. Understanding your dependency landscape prevents over-engineering and under-engineering alike.
Shared business logic (commonMain):
TransactionRepository- fetches and caches transactionsBudgetRepository- manages budget rules and calculationsSyncManager- coordinates data synchronizationCategoryClassifier- ML-based transaction categorization- Use cases:
GetTransactionsUseCase,CreateBudgetUseCase,SyncDataUseCase, etc.
Platform-specific implementations:
- HTTP client engine (OkHttp on Android, Darwin on iOS)
- Database driver (Android SQLite driver vs Native SQLite driver)
- Secure storage (Keystore on Android, Keychain on iOS)
- Push notification handling
- Biometric authentication
Configuration that varies by environment:
- API base URLs (dev, staging, production)
- Feature flags
- Logging levels
This dependency map reveals our module structure. We need layers (data, domain) and we need platform abstraction. Let's build it.
Project Structure That Scales
Here's how Budget Tracker organizes its Koin-related code:
shared/
βββ src/
β βββ commonMain/kotlin/
β β βββ com/budgettracker/
β β β βββ di/ # All Koin configuration
β β β β βββ KoinInit.kt # Entry point
β β β β βββ Modules.kt # Module aggregation
β β β β βββ modules/
β β β β βββ CoreModule.kt # Use cases, domain logic
β β β β βββ DataModule.kt # Repositories, data sources
β β β β βββ NetworkModule.kt # HTTP client, API services
β β β β βββ PlatformModule.kt # expect declaration
β β β βββ data/ # Repository implementations
β β β βββ domain/ # Use cases, business logic
β β β βββ platform/ # Platform abstractions
β β β βββ PlatformDependencies.kt # What platforms must provide
β β β
β βββ androidMain/kotlin/
β β βββ com/budgettracker/
β β βββ di/
β β β βββ PlatformModule.kt # actual implementation
β β βββ platform/
β β βββ AndroidPlatformDeps.kt
β β
β βββ iosMain/kotlin/
β βββ com/budgettracker/
β βββ di/
β β βββ PlatformModule.kt # actual implementation
β βββ platform/
β βββ IosPlatformDeps.ktA few principles behind this structure:
All Koin code lives in di/. When a new developer joins, they know exactly where to look for dependency wiring. No hunting through random files for module definitions.
Modules are organized by layer, not by feature (initially). For a medium-sized app like Budget Tracker, layer-based modules (core, data, network) are simpler than feature-based modules (auth, transactions, budgets). We'll discuss when to switch to feature modules in a later article.
Platform code mirrors common code structure. The androidMain and iosMain folders follow the same package structure as commonMain. This makes navigating between expect/actual pairs intuitive.
The Entry Point: KoinInit.kt
Every KMP app needs a single entry point for Koin initialization. This file is called from both Android and iOS, but each platform can customize the initialization.
Let's start with what Budget Tracker's KoinInit.kt looks like:
// commonMain/kotlin/com/budgettracker/di/KoinInit.kt
package com.budgettracker.di
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.KoinAppDeclaration
/**
* Initializes Koin for the entire application.
*
* @param platformModules Platform-specific modules provided by Android/iOS
* @param appDeclaration Optional Koin configuration (logging, properties, etc.)
*/
fun initKoin(
platformModules: List<Module> = emptyList(),
appDeclaration: KoinAppDeclaration = {}
) = startKoin {
appDeclaration()
modules(
// Platform-specific modules first (they provide base dependencies)
platformModules +
// Then shared modules that depend on platform implementations
listOf(
networkModule,
dataModule,
coreModule
)
)
}Notice a few design decisions here:
Platform modules come as a parameter. Rather than using expect/actual for the entire initialization, we let each platform pass its modules. This gives platforms more flexibilityβAndroid might include additional Android-specific modules that iOS doesn't need.
Module order matters. Platform modules are listed first because they provide fundamental dependencies (HTTP engine, database driver) that other modules depend on. If you reverse the order, you'll get resolution errors because networkModule tries to get an HttpClientEngine that hasn't been registered yet.
The appDeclaration parameter enables platform customization. Android might want to pass androidContext(this), while iOS might configure different logging. This lambda runs before modules are loaded.
Calling initKoin from Each Platform
Here's how Budget Tracker initializes Koin on each platform:
Android Initialization
// androidApp/src/main/kotlin/com/budgettracker/BudgetTrackerApp.kt
package com.budgettracker
import android.app.Application
import com.budgettracker.di.initKoin
import com.budgettracker.di.androidPlatformModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.logger.Level
class BudgetTrackerApp : Application() {
override fun onCreate() {
super.onCreate()
initKoin(
platformModules = listOf(androidPlatformModule(this)),
appDeclaration = {
androidLogger(if (BuildConfig.DEBUG) Level.DEBUG else Level.ERROR)
androidContext(this@BudgetTrackerApp)
}
)
}
}The Android initialization passes Context through the platform module (we'll see how shortly) and configures Android-specific logging. The androidContext call makes Context available throughout Koin via get() or androidContext().
iOS Initialization
// iosApp/Sources/iOSApp.swift
import SwiftUI
import Shared
@main
struct BudgetTrackerApp: App {
init() {
KoinInitKt.doInitKoin(
platformModules: [PlatformModuleKt.iosPlatformModule()],
appDeclaration: { _ in }
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}iOS initialization is simplerβno Context equivalent exists, so the platform module doesn't need external parameters. The doInitKoin naming is Kotlin/Native's convention for exposing initKoin to Swift.
The Platform Module Pattern
Now we reach the critical abstraction: how do we provide platform-specific implementations without polluting shared code with platform types?
Budget Tracker needs different implementations for:
- HTTP client engine (OkHttp vs Darwin)
- SQLite database driver
- Secure key-value storage
- Device information (app version, OS version)
The naive approach would be multiple expect/actual declarations:
// DON'T DO THIS - leads to scattered platform dependencies
expect fun createHttpEngine(): HttpClientEngine
expect fun createDatabaseDriver(): SqlDriver
expect fun createSecureStorage(): SecureStorage
expect fun getAppVersion(): String
// ... and 10 more expect declarationsThis scatters platform concerns across many files. Instead, we consolidate into a single interface:
// commonMain/kotlin/com/budgettracker/platform/PlatformDependencies.kt
package com.budgettracker.platform
import app.cash.sqldelight.db.SqlDriver
import com.russhwolf.settings.Settings
import io.ktor.client.engine.*
/**
* Contract for platform-specific dependencies.
*
* Each platform implements this interface, providing concrete
* implementations for services that require platform APIs.
*/
interface PlatformDependencies {
/** Ktor HTTP client engine (OkHttp on Android, Darwin on iOS) */
val httpClientEngine: HttpClientEngine
/** SQLDelight database driver */
val databaseDriver: SqlDriver
/** Multiplatform settings (SharedPreferences on Android, NSUserDefaults on iOS) */
val settings: Settings
/** Application metadata */
val appInfo: AppInfo
/** Secure storage for tokens and sensitive data */
val secureStorage: SecureStorage
}
data class AppInfo(
val appVersion: String,
val buildNumber: Int,
val osVersion: String,
val isDebug: Boolean
)
interface SecureStorage {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
fun clear()
}This interface becomes the contract between shared code and platforms. Shared code never imports Android or iOS typesβit only knows about PlatformDependencies.
Android Implementation
// androidMain/kotlin/com/budgettracker/platform/AndroidPlatformDeps.kt
package com.budgettracker.platform
import android.content.Context
import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.budgettracker.db.BudgetTrackerDatabase
import com.russhwolf.settings.SharedPreferencesSettings
import io.ktor.client.engine.*
import io.ktor.client.engine.okhttp.*
class AndroidPlatformDependencies(
private val context: Context
) : PlatformDependencies {
override val httpClientEngine: HttpClientEngine by lazy {
OkHttp.create {
// Configure OkHttp-specific options
config {
retryOnConnectionFailure(true)
}
}
}
override val databaseDriver: SqlDriver by lazy {
AndroidSqliteDriver(
schema = BudgetTrackerDatabase.Schema,
context = context,
name = "budgettracker.db"
)
}
override val settings: Settings by lazy {
SharedPreferencesSettings(
context.getSharedPreferences("budget_tracker_prefs", Context.MODE_PRIVATE)
)
}
override val appInfo: AppInfo by lazy {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
AppInfo(
appVersion = packageInfo.versionName ?: "unknown",
buildNumber = if (Build.VERSION.SDK_INT >= 28) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
},
osVersion = "Android ${Build.VERSION.RELEASE}",
isDebug = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
)
}
override val secureStorage: SecureStorage by lazy {
AndroidSecureStorage(context)
}
}
private class AndroidSecureStorage(context: Context) : SecureStorage {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override fun getString(key: String): String? = encryptedPrefs.getString(key, null)
override fun putString(key: String, value: String) {
encryptedPrefs.edit().putString(key, value).apply()
}
override fun remove(key: String) {
encryptedPrefs.edit().remove(key).apply()
}
override fun clear() {
encryptedPrefs.edit().clear().apply()
}
}Notice the by lazy initialization. Platform dependencies often involve I/O or expensive operations (creating database connections, initializing encryption). Lazy initialization defers this cost until actually needed, improving app startup time.
iOS Implementation
// iosMain/kotlin/com/budgettracker/platform/IosPlatformDeps.kt
package com.budgettracker.platform
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.budgettracker.db.BudgetTrackerDatabase
import com.russhwolf.settings.NSUserDefaultsSettings
import io.ktor.client.engine.*
import io.ktor.client.engine.darwin.*
import platform.Foundation.*
import platform.Security.*
class IosPlatformDependencies : PlatformDependencies {
override val httpClientEngine: HttpClientEngine by lazy {
Darwin.create {
configureRequest {
setAllowsCellularAccess(true)
}
}
}
override val databaseDriver: SqlDriver by lazy {
NativeSqliteDriver(
schema = BudgetTrackerDatabase.Schema,
name = "budgettracker.db"
)
}
override val settings: Settings by lazy {
NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
}
override val appInfo: AppInfo by lazy {
val bundle = NSBundle.mainBundle
AppInfo(
appVersion = bundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown",
buildNumber = (bundle.objectForInfoDictionaryKey("CFBundleVersion") as? String)?.toIntOrNull() ?: 0,
osVersion = "iOS ${UIDevice.currentDevice.systemVersion}",
isDebug = Platform.isDebugBinary
)
}
override val secureStorage: SecureStorage by lazy {
IosSecureStorage()
}
}
private class IosSecureStorage : SecureStorage {
private val serviceName = "com.budgettracker.secure"
override fun getString(key: String): String? {
val query = mapOf<Any?, Any?>(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key,
kSecReturnData to true
).toNSMutableDictionary()
val result = memScoped {
val dataRef = alloc<CFTypeRefVar>()
val status = SecItemCopyMatching(query, dataRef.ptr)
if (status == errSecSuccess) {
val data = CFBridgingRelease(dataRef.value) as? NSData
data?.let { NSString.create(it, NSUTF8StringEncoding) as? String }
} else null
}
return result
}
override fun putString(key: String, value: String) {
remove(key) // Remove existing value first
val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
val query = mapOf<Any?, Any?>(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key,
kSecValueData to data
).toNSMutableDictionary()
SecItemAdd(query, null)
}
override fun remove(key: String) {
val query = mapOf<Any?, Any?>(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key
).toNSMutableDictionary()
SecItemDelete(query)
}
override fun clear() {
val query = mapOf<Any?, Any?>(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName
).toNSMutableDictionary()
SecItemDelete(query)
}
}The iOS implementation uses Keychain for secure storageβApple's recommended approach for sensitive data. This demonstrates why a single interface with platform-specific implementations is powerful: the shared code doesn't care how secure storage works, only that it exists.
The Platform Module
Now we wire PlatformDependencies into Koin:
// androidMain/kotlin/com/budgettracker/di/PlatformModule.kt
package com.budgettracker.di
import android.content.Context
import com.budgettracker.platform.AndroidPlatformDependencies
import com.budgettracker.platform.PlatformDependencies
import org.koin.dsl.module
fun androidPlatformModule(context: Context) = module {
// Provide the platform dependencies container
single<PlatformDependencies> { AndroidPlatformDependencies(context) }
// Extract commonly-used dependencies for convenience
single { get<PlatformDependencies>().httpClientEngine }
single { get<PlatformDependencies>().databaseDriver }
single { get<PlatformDependencies>().settings }
single { get<PlatformDependencies>().appInfo }
single { get<PlatformDependencies>().secureStorage }
}// iosMain/kotlin/com/budgettracker/di/PlatformModule.kt
package com.budgettracker.di
import com.budgettracker.platform.IosPlatformDependencies
import com.budgettracker.platform.PlatformDependencies
import org.koin.dsl.module
fun iosPlatformModule() = module {
single<PlatformDependencies> { IosPlatformDependencies() }
single { get<PlatformDependencies>().httpClientEngine }
single { get<PlatformDependencies>().databaseDriver }
single { get<PlatformDependencies>().settings }
single { get<PlatformDependencies>().appInfo }
single { get<PlatformDependencies>().secureStorage }
}We extract individual dependencies for convenience. Instead of get<PlatformDependencies>().httpClientEngine throughout the codebase, modules can simply get<HttpClientEngine>().
Building the Shared Modules
With platform dependencies in place, we can build shared modules that work identically on both platforms.
Network Module
Budget Tracker communicates with three API endpoints: authentication, transactions, and analytics. Each needs its own configured HTTP client.
// commonMain/kotlin/com/budgettracker/di/modules/NetworkModule.kt
package com.budgettracker.di.modules
import com.budgettracker.BuildKonfig
import com.budgettracker.data.api.AnalyticsApi
import com.budgettracker.data.api.AuthApi
import com.budgettracker.data.api.TransactionsApi
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.koin.core.qualifier.named
import org.koin.dsl.module
val networkModule = module {
// Shared JSON configuration
single {
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
prettyPrint = get<AppInfo>().isDebug
}
}
// Base HTTP client with common configuration
single(named("base")) {
HttpClient(get()) {
install(ContentNegotiation) {
json(get())
}
install(Logging) {
logger = Logger.DEFAULT
level = if (get<AppInfo>().isDebug) LogLevel.BODY else LogLevel.NONE
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}
}
// Auth API client - separate because it doesn't send auth tokens
single(named("auth")) {
HttpClient(get()) {
install(ContentNegotiation) { json(get()) }
defaultRequest {
url(BuildKonfig.AUTH_API_URL)
}
}
}
// Main API client - includes auth token interceptor
single(named("api")) {
HttpClient(get()) {
install(ContentNegotiation) { json(get()) }
install(Logging) {
level = if (get<AppInfo>().isDebug) LogLevel.HEADERS else LogLevel.NONE
}
defaultRequest {
url(BuildKonfig.MAIN_API_URL)
}
}
}
// Analytics client - fire and forget, different error handling
single(named("analytics")) {
HttpClient(get()) {
install(ContentNegotiation) { json(get()) }
install(HttpTimeout) {
requestTimeoutMillis = 5_000 // Shorter timeout, don't block user
}
defaultRequest {
url(BuildKonfig.ANALYTICS_API_URL)
}
}
}
// API service implementations
single { AuthApi(get(named("auth"))) }
single { TransactionsApi(get(named("api")), get()) }
single { AnalyticsApi(get(named("analytics"))) }
}This module demonstrates named qualifiersβa way to register multiple instances of the same type. When you have three HttpClient instances, you need a way to distinguish them. The named() qualifier creates a unique identifier.
Data Module
The data module contains repositoriesβclasses that coordinate between local storage and remote APIs.
// commonMain/kotlin/com/budgettracker/di/modules/DataModule.kt
package com.budgettracker.di.modules
import com.budgettracker.data.repository.*
import com.budgettracker.db.BudgetTrackerDatabase
import org.koin.dsl.module
val dataModule = module {
// Database instance
single { BudgetTrackerDatabase(get()) }
// Repositories - all singletons because they manage shared resources
single<TransactionRepository> {
TransactionRepositoryImpl(
api = get(),
database = get(),
settings = get()
)
}
single<BudgetRepository> {
BudgetRepositoryImpl(
database = get(),
settings = get()
)
}
single<UserRepository> {
UserRepositoryImpl(
authApi = get(),
secureStorage = get(),
settings = get()
)
}
single<SyncRepository> {
SyncRepositoryImpl(
transactionRepo = get(),
budgetRepo = get(),
settings = get()
)
}
}Repositories are singletons because they manage shared resources like database connections and caches. Creating multiple TransactionRepository instances would mean multiple caches that could get out of sync.
Core Module
The core module contains use casesβthe business logic layer. This is where Budget Tracker's domain rules live.
// commonMain/kotlin/com/budgettracker/di/modules/CoreModule.kt
package com.budgettracker.di.modules
import com.budgettracker.domain.usecase.*
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val coreModule = module {
// Use cases - ALWAYS factories, never singletons
// Each invocation should get a fresh instance
// Transaction use cases
factoryOf(::GetTransactionsUseCase)
factoryOf(::GetTransactionDetailUseCase)
factoryOf(::CategorizeTransactionUseCase)
factoryOf(::SearchTransactionsUseCase)
// Budget use cases
factoryOf(::GetBudgetsUseCase)
factoryOf(::CreateBudgetUseCase)
factoryOf(::UpdateBudgetUseCase)
factoryOf(::DeleteBudgetUseCase)
factoryOf(::CheckBudgetStatusUseCase)
// User use cases
factoryOf(::LoginUseCase)
factoryOf(::LogoutUseCase)
factoryOf(::GetCurrentUserUseCase)
factoryOf(::UpdateUserProfileUseCase)
// Sync use cases
factoryOf(::SyncDataUseCase)
factoryOf(::GetSyncStatusUseCase)
}Notice that use cases are factories, not singletons. This is a deliberate design decision that deserves explanation.
A use case represents a single operation. When you call GetTransactionsUseCase, it fetches transactions, maybe applies some business rules, and returns results. The use case itself shouldn't hold state between invocations.
If use cases were singletons, you'd risk:
- Stale state: A use case might cache data from a previous invocation
- Concurrency bugs: Multiple callers sharing the same instance could interfere
- Memory leaks: Use cases holding references to completed operations
The factoryOf(::ClassName) syntax is Koin's shorthand for factory { ClassName(get(), get(), ...) }. It uses reflection to discover constructor parameters and resolve them automatically.
Named Qualifiers: The Type-Safe Way
Earlier we used named("auth") to distinguish HTTP clients. String-based names work, but they're error-proneβa typo in named("atuh") fails silently until runtime.
For production code, Budget Tracker uses sealed classes for type-safe qualifiers:
// commonMain/kotlin/com/budgettracker/di/Qualifiers.kt
package com.budgettracker.di
import org.koin.core.qualifier.Qualifier
import org.koin.core.qualifier.QualifierValue
sealed class ApiClient(override val value: QualifierValue) : Qualifier {
object Auth : ApiClient("auth_client")
object Main : ApiClient("main_client")
object Analytics : ApiClient("analytics_client")
}
sealed class Dispatcher(override val value: QualifierValue) : Qualifier {
object IO : Dispatcher("dispatcher_io")
object Main : Dispatcher("dispatcher_main")
object Default : Dispatcher("dispatcher_default")
}Using these qualifiers:
// In module definition
single(ApiClient.Auth) { createAuthHttpClient() }
single(ApiClient.Main) { createMainHttpClient() }
// When resolving
val authClient: HttpClient = get(ApiClient.Auth)The compiler catches typos. Refactoring updates all references. Your IDE can find all usages of a specific qualifier.
Common Setup Mistakes
Here are common mistakes teams make when setting up Koin, illustrated through our Budget Tracker example.
Mistake 1: The God Module
A common first attempt puts everything in one file:
// DON'T DO THIS
val appModule = module {
// Platform
single { AndroidPlatformDependencies(get()) }
// Network (50 lines)
single { Json { ... } }
single { HttpClient(get()) { ... } }
// ... more network stuff
// Database (30 lines)
single { BudgetTrackerDatabase(get()) }
// ... queries
// Repositories (40 lines)
single { TransactionRepositoryImpl(get(), get()) }
// ... more repos
// Use cases (60 lines)
factory { GetTransactionsUseCase(get()) }
// ... 20 more use cases
// ViewModels (40 lines)
factory { TransactionListViewModel(get(), get()) }
// ... more viewmodels
}
// 220+ lines in one moduleAfter a few months, such files become difficult to maintain. Finding anything requires scrolling through hundreds of lines. Adding a dependency means figuring out where in the file it "belongs."
The fix: Split by layer. Each module under 50 lines. If a module grows beyond that, it's probably doing too much.
Mistake 2: Circular Dependencies
A common scenario that causes issues:
single { UserRepository(get<SessionManager>()) }
single { SessionManager(get<UserRepository>()) } // π₯ Stack overflowUserRepository needed SessionManager to check authentication. SessionManager needed UserRepository to load user data on session restore. Classic chicken-and-egg.
The fix: Introduce a third component or use lazy resolution:
single { UserRepository(get()) }
single { SessionManager(userRepoProvider = { get<UserRepository>() }) }
// SessionManager now takes a provider lambda, not a direct reference
class SessionManager(
private val userRepoProvider: () -> UserRepository
) {
private val userRepo: UserRepository by lazy { userRepoProvider() }
}Mistake 3: Forgetting Module Order
initKoin {
modules(
coreModule, // Uses HttpClient
networkModule, // Provides HttpClient
)
}
// Crash: No definition found for HttpClientKoin resolves dependencies in the order modules are listed. If coreModule needs something from networkModule, networkModule must come first.
The fix: List modules in dependency orderβfoundations first, then layers that depend on them:
modules(
platformModule, // Foundation
networkModule, // Depends on platform (HttpEngine)
dataModule, // Depends on network, platform
coreModule // Depends on data
)Mistake 4: Singleton ViewModels
// DON'T DO THIS
single { TransactionListViewModel(get()) }If TransactionListViewModel is a singleton, it survives screen navigation. User goes to transaction list, back, then to transaction list againβsame ViewModel instance, stale state.
The fix: ViewModels should typically be scoped (covered in the next article) or created by the platform's ViewModel system:
// For shared ViewModels, use factory or scope
factory { TransactionListViewModel(get()) }
// Or let Android's ViewModel system manage lifecycle
// (covered in scopes article)Quick Reference: Declaration Types
| Declaration | Creates | Use For |
|---|---|---|
single { } | One instance, lives forever | Repositories, HTTP clients, databases |
factory { } | New instance every time | Use cases, mappers, validators |
scoped { } | One per scope lifetime | ViewModels, screen state (next article) |
singleOf(::Class) | Shorthand for single | When constructor params are all injected |
factoryOf(::Class) | Shorthand for factory | When constructor params are all injected |
What's Next
We've established Budget Tracker's module architecture, but we haven't addressed a critical question: what happens to dependencies when a user navigates away from a screen?
Singletons live forever. Factories create new instances constantly. Neither is appropriate for screen-specific state like ViewModels.
In the next article, we'll explore Koin's scope systemβthe mechanism for creating dependencies that live exactly as long as they should, no more, no less.