Advanced Koin Scopes: Flow, Session, and Nested Patterns

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

In the previous article, we learned to create screen-level scopesβ€”dependencies that live exactly as long as a single screen. But Budget Tracker has requirements that screen scopes can't handle alone.

Consider our onboarding flow: Welcome β†’ Connect Bank β†’ Set Budget β†’ Enable Notifications. That's four screens, but they share state. The bank account selected on screen two needs to be available on screen three. If each screen has its own scope, that shared state has nowhere to live.

This article explores scope patterns for complex real-world scenarios.

Pattern 1: Flow-Based Scopes

A flow is a sequence of screens that together complete a task. Onboarding, checkout, account setupβ€”these are flows. The key characteristic: state must persist across screens until the flow completes.

Budget Tracker's Onboarding Flow

Let's map out what our onboarding flow needs:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 Onboarding Flow Structure                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                           β”‚
β”‚  Screen 1: Welcome                                        β”‚
β”‚  β”œβ”€ Collects: User name, email preferences                β”‚
β”‚  └─ Needs: OnboardingState (write)                        β”‚
β”‚           β”‚                                               β”‚
β”‚           β–Ό                                               β”‚
β”‚  Screen 2: Connect Bank                                   β”‚
β”‚  β”œβ”€ Collects: Bank selection, account linking             β”‚
β”‚  └─ Needs: OnboardingState (read previous, write new)     β”‚
β”‚           β”‚                                               β”‚
β”‚           β–Ό                                               β”‚
β”‚  Screen 3: Set Budget                                     β”‚
β”‚  β”œβ”€ Collects: Budget categories, amounts                  β”‚
β”‚  └─ Needs: OnboardingState (read bank info for defaults)  β”‚
β”‚           β”‚                                               β”‚
β”‚           β–Ό                                               β”‚
β”‚  Screen 4: Notifications                                  β”‚
β”‚  β”œβ”€ Collects: Notification preferences                    β”‚
β”‚  └─ Needs: OnboardingState (complete and submit)          β”‚
β”‚                                                           β”‚
β”‚  Shared across ALL screens:                               β”‚
β”‚  β€’ OnboardingState - accumulated user choices             β”‚
β”‚  β€’ OnboardingAnalytics - tracks funnel progression        β”‚
β”‚  β€’ OnboardingValidator - validates data before submission β”‚
β”‚                                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

All four screens need access to OnboardingState. If we use screen scopes, each screen creates its own stateβ€”breaking the flow. If we use singletons, the onboarding state persists even after the user completes onboardingβ€”a memory waste and potential bug source if they onboard again.

The solution: a flow scope that spans all four screens.

Implementing the Flow Scope

First, define the scope and its dependencies:

// commonMain/kotlin/com/budgettracker/di/modules/OnboardingModule.kt

package com.budgettracker.di.modules

import org.koin.core.qualifier.named
import org.koin.dsl.module

val OnboardingFlowScope = named("OnboardingFlowScope")

val onboardingModule = module {

    // Flow-level scope - lives across all onboarding screens
    scope(OnboardingFlowScope) {

        // Shared state that accumulates across screens
        scoped { OnboardingState() }

        // Analytics that tracks the entire funnel
        scoped {
            OnboardingAnalytics(
                tracker = get(),
                state = get()
            )
        }

        // Validator that checks all accumulated data
        scoped {
            OnboardingValidator(
                state = get(),
                userRepository = get()
            )
        }

        // Screen-specific ViewModels - also scoped to flow
        // They share the same OnboardingState instance
        scoped(named("welcome")) {
            WelcomeViewModel(
                state = get(),
                analytics = get()
            )
        }

        scoped(named("connectBank")) {
            ConnectBankViewModel(
                state = get(),
                bankService = get(),
                analytics = get()
            )
        }

        scoped(named("setBudget")) {
            SetBudgetViewModel(
                state = get(),
                categoryService = get(),
                analytics = get()
            )
        }

        scoped(named("notifications")) {
            NotificationsViewModel(
                state = get(),
                validator = get(),
                userRepository = get(),
                analytics = get()
            )
        }
    }
}

Notice that ViewModels are inside the flow scope, not in separate screen scopes. This is intentionalβ€”when the user navigates from Welcome to Connect Bank, we want the WelcomeViewModel to stay alive (the user might press back). All ViewModels share the same OnboardingState instance.

The Flow Coordinator

To manage the flow scope's lifecycle, we create a coordinator:

// commonMain/kotlin/com/budgettracker/feature/onboarding/OnboardingFlowCoordinator.kt

package com.budgettracker.feature.onboarding

import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope

/**
 * Coordinates the onboarding flow lifecycle.
 *
 * Create when user enters onboarding, call finish() when they complete or abandon.
 */
class OnboardingFlowCoordinator : KoinComponent {

    private val flowId = "onboarding_${System.currentTimeMillis()}"

    private val scope: Scope = getKoin().createScope(flowId, OnboardingFlowScope)

    // Shared state - same instance for all screens
    val state: OnboardingState = scope.get()

    // Screen ViewModels - resolved on demand
    fun getWelcomeViewModel(): WelcomeViewModel = scope.get(named("welcome"))
    fun getConnectBankViewModel(): ConnectBankViewModel = scope.get(named("connectBank"))
    fun getSetBudgetViewModel(): SetBudgetViewModel = scope.get(named("setBudget"))
    fun getNotificationsViewModel(): NotificationsViewModel = scope.get(named("notifications"))

    /**
     * Call when onboarding completes (success) or is abandoned (user exits).
     * Cleans up all flow dependencies.
     */
    fun finish() {
        // Track completion state before cleanup
        val analytics: OnboardingAnalytics = scope.get()
        analytics.trackFlowEnd(state.isComplete)

        scope.close()
    }
}

The coordinator exposes ViewModels through methods rather than properties. This ensures lazy initializationβ€”the SetBudgetViewModel isn't created until the user actually reaches that screen.

Platform Integration

On the platform side, the coordinator lives at the navigation level:

// Android - in a NavHost or parent Activity
class OnboardingActivity : AppCompatActivity() {

    private lateinit var coordinator: OnboardingFlowCoordinator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        coordinator = OnboardingFlowCoordinator()

        // Pass coordinator to navigation graph
        supportFragmentManager.fragmentFactory = OnboardingFragmentFactory(coordinator)
    }

    override fun onDestroy() {
        super.onDestroy()
        coordinator.finish()  // Cleanup entire flow
    }

    // Called when user completes onboarding
    fun onOnboardingComplete() {
        coordinator.finish()
        startActivity(Intent(this, MainActivity::class.java))
        finish()
    }

    // Called when user abandons onboarding
    override fun onBackPressed() {
        if (supportFragmentManager.backStackEntryCount == 0) {
            coordinator.finish()  // Clean up before exiting
        }
        super.onBackPressed()
    }
}

Each Fragment gets its ViewModel from the shared coordinator:

class ConnectBankFragment : Fragment() {

    private val coordinator: OnboardingFlowCoordinator by lazy {
        (requireActivity() as OnboardingActivity).coordinator
    }

    private val viewModel: ConnectBankViewModel by lazy {
        coordinator.getConnectBankViewModel()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // ViewModel is shared across navigation
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.state.collect { /* update UI */ }
        }
    }
}

Pattern 2: Session-Based Scopes

Some dependencies should live from login until logoutβ€”longer than any single screen or flow, but not for the entire app lifetime.

Budget Tracker's session includes:

Defining the Session Scope

// commonMain/kotlin/com/budgettracker/di/modules/SessionModule.kt

package com.budgettracker.di.modules

import org.koin.core.qualifier.named
import org.koin.dsl.module

val UserSessionScope = named("UserSessionScope")

val sessionModule = module {

    scope(UserSessionScope) {

        // User-specific preferences
        scoped {
            UserPreferences(
                userId = get<SessionInfo>().userId,
                settings = get()
            )
        }

        // Notification manager with user's push token
        scoped {
            NotificationManager(
                userId = get<SessionInfo>().userId,
                pushService = get()
            )
        }

        // Sync manager for user's data
        scoped {
            SyncManager(
                userId = get<SessionInfo>().userId,
                transactionRepo = get(),
                budgetRepo = get(),
                preferences = get()
            )
        }

        // User data cache
        scoped {
            UserCache(
                userId = get<SessionInfo>().userId,
                database = get()
            )
        }
    }
}

/**
 * Information about the current session.
 * Passed when creating the session scope.
 */
data class SessionInfo(
    val userId: String,
    val authToken: String,
    val loginTime: Long = System.currentTimeMillis()
)

The SessionInfo is interestingβ€”it's not defined in the module but provided when creating the scope. This allows passing runtime data (the user ID) into scoped dependencies.

The Session Manager

// commonMain/kotlin/com/budgettracker/auth/SessionManager.kt

package com.budgettracker.auth

import org.koin.core.component.KoinComponent
import org.koin.core.scope.Scope

/**
 * Manages user session lifecycle.
 *
 * Call login() when user authenticates, logout() when they sign out.
 */
class SessionManager : KoinComponent {

    private var sessionScope: Scope? = null
    private var currentSession: SessionInfo? = null

    val isLoggedIn: Boolean
        get() = sessionScope != null

    val userId: String?
        get() = currentSession?.userId

    /**
     * Creates a new user session.
     * Call after successful authentication.
     */
    fun login(userId: String, authToken: String) {
        // Close any existing session first
        logout()

        val sessionInfo = SessionInfo(userId, authToken)
        currentSession = sessionInfo

        // Create scope with session info available
        sessionScope = getKoin().createScope(
            scopeId = "session_$userId",
            qualifier = UserSessionScope
        ).apply {
            // Declare session info in the scope
            declare(sessionInfo)
        }

        // Initialize session-scoped services
        get<SyncManager>().startBackgroundSync()
        get<NotificationManager>().registerPushToken()
    }

    /**
     * Ends the current user session.
     * Call on sign out or token expiration.
     */
    fun logout() {
        sessionScope?.let { scope ->
            // Cleanup before closing
            scope.get<SyncManager>().stopBackgroundSync()
            scope.get<NotificationManager>().unregisterPushToken()
            scope.get<UserCache>().clear()

            scope.close()
        }

        sessionScope = null
        currentSession = null
    }

    /**
     * Get a session-scoped dependency.
     * Throws if not logged in.
     */
    inline fun <reified T : Any> getSessionScoped(): T {
        return sessionScope?.get()
            ?: throw IllegalStateException("No active session. User must be logged in.")
    }

    /**
     * Get a session-scoped dependency, or null if not logged in.
     */
    inline fun <reified T : Any> getSessionScopedOrNull(): T? {
        return sessionScope?.get()
    }
}

The declare() function is keyβ€”it adds SessionInfo to the scope at runtime, making it available to all scoped dependencies. This is how you pass dynamic data (like user ID) into a scope.

Using Session Dependencies

Screen scopes can access session-scoped dependencies through the SessionManager:

// In a feature module
val transactionModule = module {

    scope(TransactionListScope) {
        scoped {
            TransactionListViewModel(
                repository = get(),
                // Access session-scoped dependency
                preferences = get<SessionManager>().getSessionScoped<UserPreferences>(),
                syncManager = get<SessionManager>().getSessionScoped<SyncManager>()
            )
        }
    }
}

Or, for cleaner code, provide session dependencies to child scopes:

// More elegant approach: link scopes
scope(TransactionListScope) {
    scoped {
        val session = get<SessionManager>()

        TransactionListViewModel(
            repository = get(),
            preferences = session.getSessionScoped(),
            syncManager = session.getSessionScoped()
        )
    }
}

Pattern 3: Nested Scopes

Sometimes a screen needs its own scope that also accesses a parent scope. Budget Tracker's transaction detail screen has an "edit mode" that needs:

When edit mode ends, edit-specific dependencies should be cleaned up, but detail dependencies should remain.

The Nested Scope Structure

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Root Scope (App)                       β”‚
β”‚  β€’ TransactionRepository                                    β”‚
β”‚  β€’ HttpClient                                               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚             TransactionDetailScope                    β”‚  β”‚
β”‚  β”‚  β€’ TransactionDetailViewModel                         β”‚  β”‚
β”‚  β”‚  β€’ TransactionFormatter                               β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚           TransactionEditScope                  β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β€’ TransactionEditViewModel                     β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β€’ TransactionFormValidator                     β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β€’ DraftManager                                 β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Closing TransactionEditScope β†’ Only edit dependencies cleaned up
Closing TransactionDetailScope β†’ Both scopes cleaned up

Implementing Nested Scopes

// commonMain/kotlin/com/budgettracker/di/modules/TransactionModule.kt

val TransactionDetailScope = named("TransactionDetailScope")
val TransactionEditScope = named("TransactionEditScope")

val transactionModule = module {

    // Parent scope: transaction detail
    scope(TransactionDetailScope) {
        scoped { TransactionDetailViewModel(get(), get()) }
        scoped { TransactionFormatter(get()) }
    }

    // Child scope: edit mode (accesses parent scope)
    scope(TransactionEditScope) {
        scoped {
            TransactionEditViewModel(
                // These come from parent scope
                detailViewModel = get(),
                formatter = get(),
                // These are edit-specific
                validator = get(),
                draftManager = get()
            )
        }
        scoped { TransactionFormValidator() }
        scoped { DraftManager(get()) }
    }
}

Creating Linked Scopes

The key to nested scopes is the linkTo function when creating the child:

class TransactionDetailComponent(transactionId: String) : KoinComponent {

    private val detailScope = getKoin().createScope(
        "detail_$transactionId",
        TransactionDetailScope
    )

    val viewModel: TransactionDetailViewModel = detailScope.get()

    // Child scope for edit mode
    private var editScope: Scope? = null

    fun enterEditMode(): TransactionEditViewModel {
        // Create child scope linked to parent
        editScope = getKoin().createScope(
            "edit_$transactionId",
            TransactionEditScope,
            source = detailScope  // Link to parent!
        )

        return editScope!!.get()
    }

    fun exitEditMode() {
        editScope?.close()  // Only cleans up edit dependencies
        editScope = null
    }

    fun destroy() {
        editScope?.close()    // Close child first
        detailScope.close()   // Then parent
    }
}

The source parameter in createScope links the child to the parent. When resolving dependencies in the edit scope, Koin first checks the edit scope, then the detail scope, then the root scope.

The FeatureScopeManager Pattern

As an app grows, managing individual scopes can become unwieldy. A centralized manager helps by:

// commonMain/kotlin/com/budgettracker/di/FeatureScopeManager.kt

package com.budgettracker.di

import org.koin.core.component.KoinComponent
import org.koin.core.qualifier.Qualifier
import org.koin.core.scope.Scope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
 * Centralized manager for feature scopes.
 *
 * Benefits:
 * - Prevents duplicate scope creation
 * - Ensures proper cleanup
 * - Provides debugging visibility
 * - Thread-safe operations
 */
class FeatureScopeManager : KoinComponent {

    private val mutex = Mutex()
    private val activeScopes = mutableMapOf<String, ScopeEntry>()

    private data class ScopeEntry(
        val scope: Scope,
        val qualifier: Qualifier,
        val createdAt: Long = System.currentTimeMillis()
    )

    /**
     * Gets or creates a scope with the given ID.
     * If the scope already exists, returns the existing instance.
     */
    suspend fun <T : Any> getOrCreateScope(
        scopeId: String,
        qualifier: Qualifier,
        block: Scope.() -> T
    ): T = mutex.withLock {
        val entry = activeScopes.getOrPut(scopeId) {
            val scope = getKoin().createScope(scopeId, qualifier)
            logScopeCreated(scopeId, qualifier)
            ScopeEntry(scope, qualifier)
        }
        block(entry.scope)
    }

    /**
     * Closes a scope by ID.
     * Safe to call even if scope doesn't exist.
     */
    suspend fun closeScope(scopeId: String) = mutex.withLock {
        activeScopes.remove(scopeId)?.let { entry ->
            logScopeClosing(scopeId, entry)
            entry.scope.close()
        }
    }

    /**
     * Closes all scopes matching a qualifier.
     * Useful for cleaning up all scopes of a type.
     */
    suspend fun closeScopesByQualifier(qualifier: Qualifier) = mutex.withLock {
        val toClose = activeScopes.filter { it.value.qualifier == qualifier }
        toClose.forEach { (id, entry) ->
            logScopeClosing(id, entry)
            entry.scope.close()
        }
        activeScopes.entries.removeAll { it.value.qualifier == qualifier }
    }

    /**
     * Closes all active scopes.
     * Call on app termination or user logout.
     */
    suspend fun closeAllScopes() = mutex.withLock {
        activeScopes.forEach { (id, entry) ->
            logScopeClosing(id, entry)
            entry.scope.close()
        }
        activeScopes.clear()
    }

    /**
     * Returns debug information about active scopes.
     */
    fun getDebugInfo(): List<ScopeDebugInfo> {
        return activeScopes.map { (id, entry) ->
            ScopeDebugInfo(
                id = id,
                qualifier = entry.qualifier.toString(),
                ageMs = System.currentTimeMillis() - entry.createdAt
            )
        }
    }

    /**
     * Checks for potential scope leaks.
     * Returns scopes that have been open longer than the threshold.
     */
    fun detectPotentialLeaks(maxAgeMs: Long = 30 * 60 * 1000): List<ScopeDebugInfo> {
        val now = System.currentTimeMillis()
        return activeScopes
            .filter { now - it.value.createdAt > maxAgeMs }
            .map { (id, entry) ->
                ScopeDebugInfo(
                    id = id,
                    qualifier = entry.qualifier.toString(),
                    ageMs = now - entry.createdAt
                )
            }
    }

    private fun logScopeCreated(id: String, qualifier: Qualifier) {
        println("🟒 SCOPE [$id] created (${qualifier})")
    }

    private fun logScopeClosing(id: String, entry: ScopeEntry) {
        val ageSeconds = (System.currentTimeMillis() - entry.createdAt) / 1000
        println("πŸ”΄ SCOPE [$id] closing after ${ageSeconds}s (${entry.qualifier})")
    }
}

data class ScopeDebugInfo(
    val id: String,
    val qualifier: String,
    val ageMs: Long
)

Using the FeatureScopeManager

// Register as singleton
val coreModule = module {
    single { FeatureScopeManager() }
}

// In a component
class TransactionDetailComponent(
    private val transactionId: String,
    private val scopeManager: FeatureScopeManager
) {
    private val scopeId = "transaction_detail_$transactionId"

    suspend fun getViewModel(): TransactionDetailViewModel {
        return scopeManager.getOrCreateScope(scopeId, TransactionDetailScope) {
            get()
        }
    }

    suspend fun destroy() {
        scopeManager.closeScope(scopeId)
    }
}

Leak Detection in Debug Builds

Add periodic leak detection:

// In your debug build Application class
class DebugApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        // Check for scope leaks every 5 minutes in debug
        if (BuildConfig.DEBUG) {
            GlobalScope.launch {
                while (true) {
                    delay(5.minutes)

                    val leaks = get<FeatureScopeManager>().detectPotentialLeaks()
                    if (leaks.isNotEmpty()) {
                        Log.w("ScopeLeaks", "Potential scope leaks detected:")
                        leaks.forEach { leak ->
                            Log.w("ScopeLeaks", "  - ${leak.id}: open for ${leak.ageMs / 1000}s")
                        }
                    }
                }
            }
        }
    }
}

Memory Management Deep Dive

Let's address the elephant in the room: how do you know your scopes aren't leaking?

The Memory Lifecycle Test

Here's a test pattern that verifies scope cleanup:

@Test
fun `transaction detail scope is properly cleaned up`() = runTest {
    // Arrange
    val scopeManager = FeatureScopeManager()
    val transactionId = "test_123"

    // Act: Create and use scope
    val viewModel = scopeManager.getOrCreateScope(
        "detail_$transactionId",
        TransactionDetailScope
    ) {
        get<TransactionDetailViewModel>()
    }

    // Verify scope exists
    assertEquals(1, scopeManager.getDebugInfo().size)

    // Act: Close scope
    scopeManager.closeScope("detail_$transactionId")

    // Verify scope is gone
    assertEquals(0, scopeManager.getDebugInfo().size)

    // Verify ViewModel is eligible for GC
    // (In real test, use WeakReference to verify)
}

WeakReference Verification

For thorough testing, use WeakReference to verify objects are garbage collected:

@Test
fun `scoped dependencies are garbage collected after scope closes`() = runTest {
    val scopeManager = FeatureScopeManager()

    // Create scope and get weak reference to ViewModel
    var viewModelRef: WeakReference<TransactionDetailViewModel>? = null

    scopeManager.getOrCreateScope("test", TransactionDetailScope) {
        val vm = get<TransactionDetailViewModel>()
        viewModelRef = WeakReference(vm)
        vm
    }

    // Close scope
    scopeManager.closeScope("test")

    // Suggest GC (not guaranteed, but helps in tests)
    System.gc()
    delay(100)

    // Verify ViewModel can be collected
    // Note: This is a weak assertion; the VM might still exist
    // if something else holds a reference
    assertNull(viewModelRef?.get(), "ViewModel should be eligible for GC")
}

Summary: Choosing the Right Scope Pattern

Scenario Pattern Example
Single screen Screen scope Transaction detail, Settings
Multi-screen flow Flow scope Onboarding, Checkout, Account setup
Logged-in user Session scope User preferences, Sync, Notifications
Sub-feature within screen Nested scope Edit mode, Filter panel
Complex app with many features FeatureScopeManager Any large app

What's Next

We've built a solid foundation for dependency lifecycles. But we've been hand-waving one critical piece: how do Android's Context and iOS's platform APIs actually get into our shared code?

In the next article, we'll deep-dive into platform dependenciesβ€”the PlatformDependencies pattern, Android-specific bridging, and handling platform features that don't exist on the other side.