Advanced Koin Scopes: Flow, Session, and Nested Patterns
Dependency Injection in Kotlin Multiplatform with Koin Part 4 of 8In 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:
UserPreferences- the logged-in user's settingsNotificationManager- handles user-specific push tokensSyncManager- background sync tied to user's dataUserCache- cached user profile and related data
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:
- Everything from the detail scope (transaction data, formatter)
- Plus edit-specific dependencies (form validator, draft manager)
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 upImplementing 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:
- Creates scopes on demand
- Ensures proper cleanup
- Provides debugging visibility
- Prevents common mistakes
// 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.