Understanding Koin Scopes: The Mental Model That Prevents Memory Leaks
Dependency Injection in Kotlin Multiplatform with Koin Part 3 of 8Imagine this common scenario: a QA engineer files a bug saying "Memory usage keeps climbing. After navigating to 10 different screens, the app uses 400MB."
Upon investigation, you discover every ViewModel was declared as a single. Every screen's state holder lives forever, accumulating data from every screen the user visited. You've created a memory leak factory.
This article is about understanding Koin's scope systemβnot just the syntax, but the mental model that helps you make correct decisions instinctively.
The Lifecycle Problem
Before we talk about scopes, let's understand the problem they solve.
In Budget Tracker, consider the TransactionDetailScreen. When a user opens it, we need:
TransactionDetailViewModel- manages screen state, handles user actionsTransactionFormatter- formats currency, dates for displayTransactionAnalytics- tracks user interactions with this screen
When the user leaves the screen, what should happen to these dependencies?
If they're singletons: They live forever. After visiting 50 transactions, you have 50 TransactionDetailViewModel instances in memory, each holding references to transaction data, coroutine scopes, and cached calculations. Memory grows unbounded.
If they're factories: A new instance is created every time they're requested. If your screen requests the ViewModel twice (once in onCreate, once after configuration change), you get two different instances with different state. Your screen shows stale data.
Neither extreme is correct. We need dependencies that:
- Exist while the screen is active
- Return the same instance within that lifetime
- Are garbage collected when the screen is dismissed
This is exactly what Koin scopes provide.
The Mental Model: Employees, Contractors, and Project Teams
Here's an analogy that helps teams understand scopes intuitively.
Singletons Are Permanent Employees
When you hire a permanent employee, they join on day one and stay until the company shuts down (or they resign). They accumulate institutional knowledge. They're always available. But you pay their salary forever, even when they have nothing to do.
single { DatabaseConnection() } // Hired once, works foreverDatabase connections should be permanent employees. You want one connection pool that persists for the app's entire lifetime, accumulating connection optimizations and caching query plans.
Factories Are Freelance Contractors
Freelancers come in for a specific task and leave when it's done. Need a function executed? Hire a contractor, get the result, they're gone. They don't accumulate state or history. Each task gets a fresh person.
factory { FormatCurrencyUseCase() } // New contractor for each jobUse cases should be contractors. FormatCurrencyUseCase takes an amount, returns a formatted string, and should hold no memory of past invocations.
Scoped Dependencies Are Project Teams
Project teams are assembled for a specific initiative. Team members collaborate, share context, and work together until the project ends. When the project completes, the team disbandsβmembers might join other teams or leave entirely.
scope<TransactionDetailScreen> {
scoped { TransactionDetailViewModel() } // Team member for this project
scoped { TransactionAnalytics() } // Another team member
}Screen-specific dependencies should be project teams. TransactionDetailViewModel and TransactionAnalytics collaborate while the user is on the transaction detail screen. When the user navigates away, the "project" ends and both are garbage collected.
Here's the visual model:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Dependency Lifecycles β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β SINGLETON (Permanent Employee) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββΆ β
β App Start App End β
β "I'm here for the long haul" β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β SCOPED (Project Team) β
β ββββββββββββββββββββββββββββββ β
β β Screen A active β β
β ββββββ΄βββββββββββββββββββββββββββββ΄ββββββββββββββββββββββΆ β
β β² Created β² Destroyed β
β "I exist for this screen's lifetime" β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β FACTORY (Freelance Contractor) β
β βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ βββΆ β
β "Each call creates a new me" β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββMaking the Decision: A Flowchart
When you're adding a new dependency, walk through this decision tree:
βββββββββββββββββββββββββββ
β Adding a dependency? β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β Does it hold state or β
β manage resources? β
βββββββββββββ¬ββββββββββββββ
β β
NO β β YES
βΌ βΌ
ββββββββββββββββ βββββββββββββββββββββββ
β Use FACTORY β β Should the state β
β β β persist for the β
β Examples: β β entire app? β
β β’ Use cases β ββββββββββββ¬βββββββββββ
β β’ Mappers β β β
β β’ Validators β YES β β NO
β β’ Builders β βΌ βΌ
ββββββββββββββββ βββββββββββββ βββββββββββββββββ
βUse SINGLE β β Use SCOPED β
β β β β
β Examples: β β Examples: β
β β’ DB conn β β β’ ViewModels β
β β’ HTTP β β β’ Screen stateβ
β β’ Caches β β β’ Feature β
β β’ Auth β β coordinatorsβ
βββββββββββββ βββββββββββββββββLet's apply this to Budget Tracker's transaction detail screen:
| Dependency | Holds State? | App-wide? | Declaration |
|---|---|---|---|
TransactionRepository | Yes (cache) | Yes | single |
TransactionDetailViewModel | Yes (screen state) | No, screen-specific | scoped |
FormatTransactionUseCase | No | N/A | factory |
TransactionAnalytics | Yes (event queue) | No, screen-specific | scoped |
HttpClient | Yes (connections) | Yes | single |
Your First Scope: Transaction Detail Screen
Let's implement a scope for Budget Tracker's transaction detail screen. We'll start with the module definition, then show how to create and close the scope.
Step 1: Define the Scope
First, we create a scope identifier. Koin needs to know what "kind" of scope we're creating:
// commonMain/kotlin/com/budgettracker/di/modules/TransactionModule.kt
package com.budgettracker.di.modules
import com.budgettracker.feature.transaction.detail.TransactionDetailViewModel
import com.budgettracker.feature.transaction.detail.TransactionAnalytics
import org.koin.core.qualifier.named
import org.koin.dsl.module
// Scope identifier - used to create scope instances
val TransactionDetailScope = named("TransactionDetailScope")
val transactionModule = module {
// These are singletons - shared across the app
single { TransactionRepository(get(), get()) }
// These are factories - new instance each call
factoryOf(::GetTransactionDetailUseCase)
factoryOf(::FormatTransactionUseCase)
// These are scoped - live within TransactionDetailScope
scope(TransactionDetailScope) {
scoped {
TransactionDetailViewModel(
getTransactionDetail = get(),
formatTransaction = get(),
repository = get()
)
}
scoped {
TransactionAnalytics(
tracker = get(), // Singleton analytics tracker
screenName = "transaction_detail"
)
}
// Scoped CoroutineScope - cancels when scope closes
scoped {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
}
}
}Notice what's happening here:
singledependencies exist outside any scopeβthey're app-wide singletonsfactorydependencies also exist outside scopesβthey're created fresh each timescope(TransactionDetailScope)creates a container for related scoped dependenciesscopeddependencies inside the scope block share the same lifetime
The CoroutineScope is particularly important. When the user leaves the screen, we want to cancel any in-flight operations. By making the CoroutineScope scoped, it's automatically garbage collected when the scope closes, which cancels all its coroutines.
Step 2: Create a Scope Instance
When the user navigates to the transaction detail screen, we create a scope instance:
// commonMain/kotlin/com/budgettracker/feature/transaction/detail/TransactionDetailComponent.kt
package com.budgettracker.feature.transaction.detail
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.scope.Scope
/**
* Component that manages the transaction detail screen's dependencies.
*
* Create when entering the screen, call destroy() when leaving.
*/
class TransactionDetailComponent(
private val transactionId: String
) : KoinComponent {
// Create a scope instance with a unique ID
private val scope: Scope = getKoin().createScope(
scopeId = "transaction_detail_$transactionId",
qualifier = TransactionDetailScope
)
// Resolve dependencies from our scope
val viewModel: TransactionDetailViewModel = scope.get()
val analytics: TransactionAnalytics = scope.get()
/**
* Call this when the screen is dismissed.
* Closes the scope and allows all scoped dependencies to be garbage collected.
*/
fun destroy() {
scope.close()
}
}Two critical points about scope creation:
Unique scope IDs. The scopeId parameter must be unique for each scope instance. We include transactionId so that if the user somehow opens two transaction detail screens (e.g., in split-screen), each gets its own scope. If you reuse scope IDs, you'll get errors about scopes already existing.
The scope qualifier must match. The qualifier parameter must match what we defined in the module (TransactionDetailScope). This tells Koin which scope { } block contains the dependencies we want.
Step 3: Wire Up the Lifecycle
Now we need to create the component when entering the screen and destroy it when leaving. This is where platform code comes in.
For shared code, we can define a factory function:
// commonMain/kotlin/com/budgettracker/feature/transaction/detail/TransactionDetailScreen.kt
package com.budgettracker.feature.transaction.detail
/**
* Creates a new TransactionDetailComponent.
*
* Platform code is responsible for:
* 1. Calling this when the screen appears
* 2. Calling component.destroy() when the screen disappears
*/
fun createTransactionDetailComponent(transactionId: String): TransactionDetailComponent {
return TransactionDetailComponent(transactionId)
}On Android, you might wire this to a Fragment's lifecycle:
// androidMain or androidApp
class TransactionDetailFragment : Fragment() {
private var component: TransactionDetailComponent? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val transactionId = arguments?.getString("transactionId") ?: return
component = createTransactionDetailComponent(transactionId)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val viewModel = component?.viewModel ?: return
// Observe ViewModel state
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
// Update UI
}
}
}
override fun onDestroy() {
super.onDestroy()
component?.destroy() // Close scope, cleanup dependencies
component = null
}
}On iOS with SwiftUI, you'd use a similar pattern with onAppear and onDisappear:
struct TransactionDetailView: View {
let transactionId: String
@StateObject private var holder: ComponentHolder
init(transactionId: String) {
self.transactionId = transactionId
_holder = StateObject(wrappedValue: ComponentHolder(
create: { TransactionDetailComponentKt.createTransactionDetailComponent(transactionId: transactionId) }
))
}
var body: some View {
TransactionContent(viewModel: holder.component.viewModel)
.onDisappear {
holder.component.destroy()
}
}
}Understanding Scope Resolution
When you request a dependency from a scope, Koin follows a specific resolution order. Understanding this helps debug "dependency not found" errors.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Scope Resolution Order β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Request: scope.get<TransactionAnalytics>() β
β β
β 1. Check THIS scope for scoped { TransactionAnalytics } β
β ββ Found? Return it (create if first time) β
β β
β 2. Check ROOT scope for single { TransactionAnalytics } β
β ββ Found? Return the singleton β
β β
β 3. Check ROOT scope for factory { TransactionAnalytics } β
β ββ Found? Create and return new instance β
β β
β 4. Not found anywhere β
β ββ Throw NoBeanDefFoundException β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThis means scoped dependencies can access singletons and factories. In our TransactionDetailViewModel, we inject TransactionRepository (a singleton) and GetTransactionDetailUseCase (a factory). Koin resolves these from the root scope automatically.
scoped {
TransactionDetailViewModel(
getTransactionDetail = get(), // Factory from root scope
formatTransaction = get(), // Factory from root scope
repository = get() // Singleton from root scope
)
}Common Scope Mistakes (And How to Avoid Them)
Here are common mistakes teams make with Koin scopesβlearn from these examples so you don't have to make them yourself.
Mistake 1: Forgetting to Close Scopes
class TransactionDetailFragment : Fragment() {
private val component = createTransactionDetailComponent(transactionId)
// No onDestroy override - scope never closed!
}The scope stays open forever. The ViewModel stays in memory. After navigating to 10 transactions, you have 10 ViewModels in memory.
The fix: Always pair scope creation with scope destruction. Make it a code review checklist item: "Does every createScope have a corresponding scope.close()?"
Mistake 2: Reusing Scope IDs
// First time user opens transaction detail
val scope1 = createScope("transaction_detail", TransactionDetailScope)
// User navigates back, then opens another transaction
val scope2 = createScope("transaction_detail", TransactionDetailScope)
// π₯ Crash: Scope with id 'transaction_detail' already exists!The first scope was never closed, so the ID is still in use.
The fix: Use unique IDs that include identifying information:
val scope = createScope(
scopeId = "transaction_detail_${transactionId}_${System.currentTimeMillis()}",
qualifier = TransactionDetailScope
)The timestamp ensures uniqueness even if the same transaction is opened twice quickly.
Mistake 3: Singleton Holding Scoped Reference
// In a singleton
single {
SyncManager(
viewModel = get<TransactionDetailViewModel>() // π₯ This is scoped!
)
}A singleton tries to inject a scoped dependency. This fails because the scoped dependency doesn't exist in the root scopeβit only exists when a TransactionDetailScope is active.
The fix: Singletons should never depend on scoped dependencies directly. If a singleton needs to interact with screen-specific state, use events or callbacks:
single {
SyncManager(
onSyncComplete = { event ->
// Broadcast event that screens can observe
eventBus.emit(event)
}
)
}Mistake 4: Scoped Coroutine Scope with Wrong Dispatcher
scoped {
CoroutineScope(SupervisorJob() + Dispatchers.Main)
}On iOS, Dispatchers.Main can cause issues in certain scenarios.
The fix: Use Dispatchers.Main.immediate for better cross-platform behavior:
scoped {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
}The .immediate variant dispatches immediately if already on the main thread, avoiding unnecessary dispatch overhead.
Debugging Scopes
When scope issues arise, visibility is your friend. Add logging to track scope lifecycle:
class TransactionDetailComponent(
private val transactionId: String
) : KoinComponent {
private val scopeId = "transaction_detail_$transactionId"
private val scope: Scope = getKoin().createScope(scopeId, TransactionDetailScope).also {
println("π’ SCOPE CREATED: $scopeId")
}
val viewModel: TransactionDetailViewModel = scope.get()
fun destroy() {
println("π΄ SCOPE CLOSING: $scopeId")
scope.close()
}
}In debug builds, this shows you exactly when scopes are created and destroyed:
π’ SCOPE CREATED: transaction_detail_txn_123
π’ SCOPE CREATED: transaction_detail_txn_456
π΄ SCOPE CLOSING: transaction_detail_txn_123
π΄ SCOPE CLOSING: transaction_detail_txn_456If you see creates without corresponding closes, you've found a leak.
For production debugging, you can query active scopes:
fun debugActiveScopes() {
val koin = getKoin()
val scopeRegistry = koin.scopeRegistry
println("=== Active Scopes ===")
// Note: This accesses internal API, use only for debugging
scopeRegistry.rootScope.let { root ->
println("Root scope: ${root.id}")
}
}Android Scope Archetypes (Koin 4.1+)
For Android development, Koin 4.1 introduces Scope Archetypesβpredefined scope types for common Android patterns. Instead of manually creating scope qualifiers for every screen, you can use built-in archetypes:
module {
// Activity scope archetype - for all activities
activityScope {
scoped { MyPresenter(get()) }
}
// Fragment scope archetype - for all fragments
fragmentScope {
scoped { MyFragmentPresenter(get()) }
}
// ViewModel scope archetype - for ViewModel-scoped dependencies
viewModelScope {
scopedOf(::Session)
}
}And in your Android components:
class MyActivity : AppCompatActivity(), AndroidScopeComponent {
// Use archetype-based scope
override val scope: Scope by activityScope()
val presenter: MyPresenter by inject()
}This reduces boilerplate for common Android patterns while still allowing custom scopes (using named()) for complex scenarios like multi-screen flows.
Summary: The Scope Checklist
Before implementing a new screen with scoped dependencies:
- Identify what needs to be scoped - ViewModels, screen state, analytics trackers
- Identify what stays singleton - Repositories, HTTP clients, databases
- Identify what stays factory - Use cases, mappers, formatters
- Create a scope qualifier -
named("MyScreenScope")or use archetypes on Android - Create a component class - Encapsulates scope creation and access
- Wire up lifecycle - Create on enter, destroy on exit
- Test the lifecycle - Navigate back and forth, check memory
What's Next
Screen-level scopes handle the common case, but Budget Tracker has more complex requirements:
- Onboarding flow: Four screens that share state until onboarding completes
- User session: Dependencies that live from login until logout
- Nested scopes: Edit mode within a detail screen
In the next article, we'll explore advanced scope patterns: flow-based scopes, session scopes, nested scopes, and the FeatureScopeManager pattern that keeps everything organized.