Production-Ready Koin: Performance, Debugging, and Reliability
Dependency Injection in Kotlin Multiplatform with Koin Part 8 of 8Imagine this scenario: your app crashes at 2:47 AM for a significant percentage of users. The stack trace: NoBeanDefFoundException. A dependency that worked for months suddenly isn't resolving.
The root cause might take hours to find. The fix takes five minutes.
This final article is about building the visibility and patterns that prevent 4 AM debugging sessions.
A Common Production Bug Pattern
Let's walk through a typical scenario that many teams encounter.
Consider an Android app initialization that looks like this:
class BudgetTrackerApplication : Application() {
override fun onCreate() {
super.onCreate()
// These ran concurrently
GlobalScope.launch {
initializeAnalytics() // Async init
}
initKoin(createPlatformModule(this)) // Koin init
}
}
private suspend fun initializeAnalytics() {
// Some async setup work
delay(100)
// π₯ Race condition: Koin might not be ready yet!
val tracker = getKoin().get<AnalyticsTracker>()
tracker.initialize()
}The analytics initialization sometimes completes before Koin finishes starting. On most devices, Koin starts faster. But on older devices under memory pressure, the race goes the other wayβcausing a crash.
This type of bug can exist since launch but only manifests when:
- Device was slow (older hardware)
- Memory was constrained (many apps open)
- Analytics initialized quickly (network was fast)
All three conditions happening together is rareβuntil it isn't.
The Fix
class BudgetTrackerApplication : Application() {
override fun onCreate() {
super.onCreate()
// Koin first, always
initKoin(createPlatformModule(this))
// Then other initialization that might need Koin
lifecycleScope.launch {
initializeAnalytics()
}
}
}Simple, but you only find it because crash reporting shows the stack trace pointing at Koin. Without that visibility, you'd still be hunting.
Lesson: Never access Koin until you're certain it's initialized. Never initialize Koin asynchronously.
Lazy Module Loading
A large app might have dozens of feature modules. Loading them all at startup wastes time and memoryβusers might never visit half of them.
Koin provides lazyModule for background loading (stable since Koin 4.0.4):
// Define modules that can load lazily
val analyticsModule = lazyModule {
singleOf(::AnalyticsTracker)
singleOf(::CrashReporter)
singleOf(::PerformanceMonitor)
}
val premiumModule = lazyModule {
singleOf(::PremiumFeatureService)
singleOf(::BillingClient)
singleOf(::SubscriptionManager)
}
// Core module loads immediately, includes lazy modules
val coreModule = module {
includes(analyticsModule) // lazyModules can be included
singleOf(::UserRepository)
singleOf(::TransactionRepository)
}
// Initialization
fun initKoin() = startKoin {
// Sync modules - available immediately
modules(coreModule, networkModule)
// Async modules - load in background
lazyModules(analyticsModule, premiumModule)
}For code that needs to wait for lazy modules (APIs stable since Koin 4.0.4):
// Wait for all background loading
suspend fun ensureModulesReady() {
getKoin().waitAllStartJobs()
}
// Or run callback when ready
fun onModulesReady(block: (Koin) -> Unit) {
getKoin().runOnKoinStarted(block)
}The Feature Registry Pattern
For fine-grained control, a feature registry pattern works well:
// commonMain/kotlin/com/budgettracker/di/FeatureRegistry.kt
enum class Feature {
PROFILE,
SETTINGS,
ANALYTICS,
PREMIUM,
EXPORT,
BUDGETS
}
object FeatureRegistry {
private val loadedFeatures = mutableSetOf<Feature>()
private val mutex = Mutex()
private val featureModules = mapOf(
Feature.PROFILE to listOf(profileModule, profileViewModelModule),
Feature.SETTINGS to listOf(settingsModule),
Feature.ANALYTICS to listOf(analyticsModule, reportingModule),
Feature.PREMIUM to listOf(premiumModule, billingModule),
Feature.EXPORT to listOf(exportModule),
Feature.BUDGETS to listOf(budgetModule, budgetViewModelModule)
)
/**
* Ensures a feature's modules are loaded.
* Safe to call multiple times - loads only once.
*/
suspend fun ensureLoaded(feature: Feature) = mutex.withLock {
if (feature in loadedFeatures) return@withLock
featureModules[feature]?.let { modules ->
getKoin().loadModules(modules)
loadedFeatures.add(feature)
Logger.d { "Loaded feature: $feature" }
}
}
/**
* Unloads a feature's modules to free memory.
*/
suspend fun unload(feature: Feature) = mutex.withLock {
if (feature !in loadedFeatures) return@withLock
featureModules[feature]?.let { modules ->
getKoin().unloadModules(modules)
loadedFeatures.remove(feature)
Logger.d { "Unloaded feature: $feature" }
}
}
fun isLoaded(feature: Feature): Boolean = feature in loadedFeatures
}Navigation triggers loading automatically:
// In navigation layer
fun navigateToProfile() {
lifecycleScope.launch {
FeatureRegistry.ensureLoaded(Feature.PROFILE)
navigator.push(ProfileScreen())
}
}
// Predictive loading - user might go here next
fun onHomeScreenVisible() {
lifecycleScope.launch {
// Preload likely destinations
FeatureRegistry.ensureLoaded(Feature.BUDGETS)
}
}Measuring Startup Performance
You can't optimize what you don't measure:
fun initKoin(platformModule: Module): Long {
val startTime = TimeSource.Monotonic.markNow()
startKoin {
modules(platformModule, coreModule, networkModule)
}
val duration = startTime.elapsedNow()
Logger.i { "Koin initialized in ${duration.inWholeMilliseconds}ms" }
// Report to analytics for monitoring
Analytics.trackTiming("koin_init_ms", duration.inWholeMilliseconds)
return duration.inWholeMilliseconds
}What's Normal?
Typical benchmarks for KMP apps:
| App Size | Definition Count | Expected Init Time | Investigate If |
|---|---|---|---|
| Small | < 30 | < 25ms | > 50ms |
| Medium | 30-80 | 25-60ms | > 100ms |
| Large | 80-150 | 60-120ms | > 200ms |
| Very Large | > 150 | 120-200ms | > 300ms |
If your numbers are significantly higher, expensive work is happening during initialization:
// π΄ SLOW - Database connects during init
single {
Database.connect(connectionString) // Blocking I/O!
}
// π’ FAST - Connection happens on first access
single {
lazy { Database.connect(connectionString) }
}Production Debugging Toolkit
Custom Production Logger
Debug logging is helpful in development, but production needs structured logging that reports to crash services:
// commonMain/kotlin/com/budgettracker/di/ProductionKoinLogger.kt
class ProductionKoinLogger(
private val crashReporter: CrashReporter
) : Logger() {
override fun display(level: Level, msg: MESSAGE) {
when (level) {
Level.ERROR -> {
crashReporter.log("Koin Error: $msg")
crashReporter.recordException(KoinDiagnosticException(msg))
}
Level.WARNING -> {
crashReporter.log("Koin Warning: $msg")
}
Level.INFO -> {
// Only log in verbose mode
if (FeatureFlags.verboseDiLogging) {
crashReporter.log("Koin: $msg")
}
}
else -> {
// Debug/None - ignore in production
}
}
}
}
class KoinDiagnosticException(message: String) : Exception("Koin: $message")Enable in production:
fun initKoin(platformModule: Module) = startKoin {
logger(
if (BuildConfig.DEBUG) {
PrintLogger(Level.DEBUG)
} else {
ProductionKoinLogger(get())
}
)
modules(...)
}Dependency Resolution Tracing
When debugging slow screens, trace resolution time:
inline fun <reified T : Any> tracedGet(tag: String = ""): T {
val start = TimeSource.Monotonic.markNow()
val instance = getKoin().get<T>()
val duration = start.elapsedNow()
val className = T::class.simpleName
if (duration.inWholeMilliseconds > 50) {
Logger.w { "β οΈ Slow resolution: $className took ${duration.inWholeMilliseconds}ms [$tag]" }
Analytics.trackEvent("slow_di_resolution", mapOf(
"class" to className,
"duration_ms" to duration.inWholeMilliseconds,
"tag" to tag
))
} else {
Logger.d { "Resolved $className in ${duration.inWholeMicroseconds}ΞΌs [$tag]" }
}
return instance
}
// Usage
val repository = tracedGet<TransactionRepository>("TransactionList.init")The Dependency Graph Dump
For debugging complex issues, dump the current state:
fun dumpKoinState(): String = buildString {
val koin = getKoin()
appendLine("βββββββββββββββββββββββββββββββββββββββ")
appendLine(" KOIN STATE DUMP ")
appendLine("βββββββββββββββββββββββββββββββββββββββ")
appendLine()
// Count definitions by type
val definitions = koin.instanceRegistry.instances
appendLine("π Statistics:")
appendLine(" Total definitions: ${definitions.size}")
appendLine(" Singletons: ${definitions.count { it.value.beanDefinition.kind == Kind.Singleton }}")
appendLine(" Factories: ${definitions.count { it.value.beanDefinition.kind == Kind.Factory }}")
appendLine(" Scoped: ${definitions.count { it.value.beanDefinition.kind == Kind.Scoped }}")
appendLine()
appendLine("π¦ Registered Types:")
definitions.forEach { (key, factory) ->
val kind = when (factory.beanDefinition.kind) {
Kind.Singleton -> "SINGLE"
Kind.Factory -> "FACTORY"
Kind.Scoped -> "SCOPED"
else -> "OTHER"
}
appendLine(" [$kind] ${key.primaryType.simpleName}")
}
}Call this from a debug menu or admin screen for production troubleshooting.
Error Handling Patterns
Graceful Degradation
Not every dependency is critical. Use optional resolution for non-essential features:
// Critical - crash if missing
val userRepository: UserRepository = get()
// Optional - null if not configured
val analyticsTracker: AnalyticsTracker? = getOrNull()
// Optional with fallback
val featureFlags: FeatureFlags = getOrNull() ?: DefaultFeatureFlags()Wrap in a helper for consistent behavior:
inline fun <reified T : Any> safeGet(
logMissing: Boolean = true,
fallback: () -> T? = { null }
): T? {
return try {
getKoin().get<T>()
} catch (e: NoBeanDefFoundException) {
if (logMissing) {
Logger.w { "Optional dependency ${T::class.simpleName} not found" }
}
fallback()
}
}The Circuit Breaker Pattern
For dependencies that can fail during resolution (external services, network-dependent initialization):
class ResilientDependencyProvider<T : Any>(
private val name: String,
private val provider: () -> T,
private val fallback: () -> T,
private val maxFailures: Int = 3,
private val resetAfter: Duration = 30.seconds
) {
private var failures = 0
private var lastFailure: TimeSource.Monotonic.ValueTimeMark? = null
private var circuitOpen = false
fun get(): T {
// Check if circuit should reset
if (circuitOpen) {
val elapsed = lastFailure?.elapsedNow() ?: Duration.ZERO
if (elapsed > resetAfter) {
Logger.i { "Circuit breaker reset for $name" }
circuitOpen = false
failures = 0
} else {
return fallback()
}
}
return try {
provider().also { failures = 0 }
} catch (e: Exception) {
failures++
lastFailure = TimeSource.Monotonic.markNow()
if (failures >= maxFailures) {
circuitOpen = true
Logger.e(e) { "Circuit breaker OPEN for $name after $failures failures" }
CrashReporter.log("Circuit breaker opened: $name")
}
fallback()
}
}
}Health Monitoring
In production, validate DI health periodically:
class KoinHealthCheck {
data class HealthStatus(
val isHealthy: Boolean,
val issues: List<String>,
val definitionCount: Int,
val scopeCount: Int,
val timestamp: Long = System.currentTimeMillis()
)
private val criticalDependencies = listOf(
"UserRepository" to UserRepository::class,
"TransactionRepository" to TransactionRepository::class,
"ApiService" to ApiService::class
)
fun check(): HealthStatus {
val issues = mutableListOf<String>()
// Verify critical dependencies resolve
criticalDependencies.forEach { (name, clazz) ->
try {
getKoin().get<Any>(clazz, null, null)
} catch (e: Exception) {
issues.add("Failed to resolve $name: ${e.message}")
}
}
val definitionCount = getKoin().instanceRegistry.instances.size
val scopeCount = countActiveScopes()
// Warn on potential issues
if (scopeCount > 50) {
issues.add("High scope count ($scopeCount) - potential memory leak")
}
return HealthStatus(
isHealthy = issues.isEmpty(),
issues = issues,
definitionCount = definitionCount,
scopeCount = scopeCount
)
}
private fun countActiveScopes(): Int {
// Implementation depends on Koin version
return try {
val registry = getKoin().scopeRegistry
// Access internal scope count
registry.rootScope.let { 1 } // Simplified
} catch (e: Exception) {
-1
}
}
}Run health checks periodically and report to monitoring:
class KoinHealthMonitor(private val scope: CoroutineScope) {
private val healthCheck = KoinHealthCheck()
fun startMonitoring(interval: Duration = 5.minutes) {
scope.launch {
while (isActive) {
delay(interval)
val health = healthCheck.check()
// Report metrics
Metrics.gauge("koin.definitions", health.definitionCount)
Metrics.gauge("koin.scopes", health.scopeCount)
Metrics.gauge("koin.healthy", if (health.isHealthy) 1 else 0)
if (!health.isHealthy) {
Logger.e { "Koin health check failed: ${health.issues}" }
CrashReporter.log("Koin unhealthy: ${health.issues.joinToString()}")
}
}
}
}
}The Production Checklist
Before shipping, verify:
- Module verification runs in CI - Catches misconfigurations before production
- Koin errors log to crash reporting - Visibility into production issues
- Startup time is measured and tracked - Catch regressions
- Critical dependencies are health-checked - Know when something's wrong
- Scopes are properly closed - Monitor scope count for leaks
- Lazy loading is configured - Only load what's needed
- Fallbacks exist for optional features - Graceful degradation
Patterns That Prevent Production Issues
Looking at common incidents and near-misses in KMP apps, these patterns prevent problems:
- Synchronous Koin initialization - No race conditions possible
- Module verification in CI - Catches most DI bugs before production
- Production logging to crash service - See patterns before users report
- Health monitoring - Know about scope leaks before they cause OOMs
- Feature flags for module changes - Gradual rollout catches edge cases
- Scope count monitoring - Growing scope count = leak in progress
- Lazy loading for features - Significantly faster cold start
Series Conclusion
Over these eight articles, we've built a complete picture of dependency injection in Kotlin Multiplatform:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β KMP + Koin: The Complete Picture β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Part 1: Why Koin? β
β ββ Framework comparison, decision criteria β
β β
β Part 2: Setting Up Koin Right β
β ββ Module organization, project structure β
β β
β Part 3: Understanding Scopes β
β ββ Mental model, screen-level scopes β
β β
β Part 4: Advanced Scopes β
β ββ Flows, sessions, nested scopes β
β β
β Part 5: Platform Dependencies β
β ββ Android Context, platform abstraction β
β β
β Part 6: iOS & Swift Integration β
β ββ Swift-friendly APIs, SwiftUI, Flow bridging β
β β
β Part 7: Testing Strategies β
β ββ Fakes, isolation, verification β
β β
β Part 8: Production Patterns (this article) β
β ββ Performance, debugging, monitoring β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββDependency injection in KMP isn't harder than single-platform DIβit's just different. The patterns that work on Android don't always transfer directly. But with the right abstractions, proper lifecycle management, and visibility into your DI layer, you can build apps that are maintainable, testable, and reliable across platforms.
Throughout this series, we've used our fictional Budget Tracker app to illustrate patterns that work in real production KMP applications. These same patterns can power your apps too.
Happy injecting.