Production-Ready Koin: Performance, Debugging, and Reliability

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

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

  1. Device was slow (older hardware)
  2. Memory was constrained (many apps open)
  3. 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:

Patterns That Prevent Production Issues

Looking at common incidents and near-misses in KMP apps, these patterns prevent problems:

  1. Synchronous Koin initialization - No race conditions possible
  2. Module verification in CI - Catches most DI bugs before production
  3. Production logging to crash service - See patterns before users report
  4. Health monitoring - Know about scope leaks before they cause OOMs
  5. Feature flags for module changes - Gradual rollout catches edge cases
  6. Scope count monitoring - Growing scope count = leak in progress
  7. 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.