paint-brush
How to work with Koin Scopes in Jetpack Compose Navigationby@arttttt
116 reads

How to work with Koin Scopes in Jetpack Compose Navigation

by Android InsightsFebruary 18th, 2025
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

When developing modern Android applications, convenient screen navigation and effective dependency management play a crucial role.
featured image - How to work with Koin Scopes in Jetpack Compose Navigation
Android Insights HackerNoon profile picture
0-item

Hello, Hackernoon!


My name is Artem, and I’m the author and host of the YouTube and Telegram channels Android Insights.


When developing modern Android applications, convenient screen navigation and effective dependency management play a crucial role. Jetpack Compose Navigation is a library from Google that provides a declarative approach to organizing navigation in apps built with Jetpack Compose. It enables you to work with navigation graphs and supports arguments, deeplinks, and state preservation.


Koin is a lightweight and user-friendly dependency injection (DI) framework for applications. It offers a declarative syntax for defining modules as well as support for scoping, which helps manage the lifecycle of dependencies.


In this article, I will discuss how to use Koin scopes in combination with Jetpack Compose Navigation to efficiently manage dependencies at different levels of the navigation graph.

The Problem

Imagine we need to design the following sequence of screens:

For example, a simplified version of the code might look like this:

NavHost(
    navController = rememberNavController(),
    startDestination = Screen.EmailInput
) {

    composable<Screen.EmailInput> {
        /* omitted code */
    }

    composable<Screen.OTP> {
        /* omitted code */
    }

    composable<Screen.PinCode> {
        /* omitted code */
    }
}


If you’re familiar with Jetpack Compose Navigation, everything looks quite standard:

  • We create a NavHost, passing it a NavController and an entry point.
  • We declare the screens using the composable function.


In our project, we also use Koin, which is configured in the usual way.


Launching Koin within the Application class:

class App : Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@App)
        }
    }
}


And calling the library function with basic settings:

KoinAndroidContext {
    Content() // navigation code inside
}


Now that the basic setup is done, let’s get to the heart of the problem. Suppose we have an AuthManager. The very same instance of AuthManager should be available across all three screens within the authentication flow. How can we achieve this?


Using factory is not an option here, because it would create a new instance of AuthManager for each screen, which contradicts the requirements.


The simplest approach would be to declare AuthManager as a singleton, for example:

val authModule = module {
    singleOf(::AuthManager)
}


Yes, AuthManager would be reused as needed, but this introduces another problem — the AuthManager would remain in memory until the application is closed. Over the app’s lifetime, many such objects might accumulate, leading to higher resource usage. Moreover, let’s assume that after a logout and re-entry into the authentication flow, AuthManager should be re-created for some reason.


How can we solve this? Before reading on, I invite you to try to solve this problem and share your solution in the comments.

The Solution

In this specific case, scopes are an excellent fit. In short, a scope allows you to limit the lifetime of objects within the DI graph provided by Koin. If you’d like to learn more about scopes, I recommend reading the official documentation.


It’s also worth isolating the navigation flow into a separate subgraph. I’ll explain the reasons for this shortly. If you’re not familiar with nested navigation graphs, please refer to the documentation as a starting point.


To create a subgraph, we use the navigation function. The navigation setup code then looks like this:

@Composable
private fun Content() {
    NavHost(
        navController = rememberNavController(),
        startDestination = Screen.AuthGraph
    ) {

        navigation<Screen.AuthGraph>(
            startDestination = Screen.AuthGraph.EmailInput
        ) {
            composable<Screen.AuthGraph.EmailInput> {
                /* omitted code */
            }

            composable<Screen.AuthGraph.OTP> {
                /* omitted code */
            }

            composable<Screen.AuthGraph.PinCode> {
                /* omitted code */
            }
        }    
    }
}


There aren’t many changes: our entry point for the NavHost is now Screen.AuthGraph. Also, as mentioned, I’m using the navigation function to create a nested navigation graph. Like NavHost, the navigation function requires a mandatory startDestination parameter to set the entry point (in fact, you can navigate to any screen within the subgraph; startDestination merely acts as a fallback).


Now let’s delve a bit deeper into how Compose Navigation works. If you look at the composable function, you’ll see that its lambda parameter content receives a NavBackStackEntry. The NavBackStackEntry implements interfaces such as LifecycleOwner, ViewModelStoreOwner, HasDefaultViewModelProviderFactory, and SavedStateRegistryOwner. In other words, each composable has its own lifecycle, can save and restore its state, and—most importantly for us—can create ViewModels and clear them properly upon exit. If you continue exploring the library, you’ll notice that the navigation function creates its own NavDestination. This means that when navigating to one of the screens in the nested navigation graph, not one but two NavBackStackEntry objects are created: one for the navigation function (the root of the subgraph) and one for the specific screen within the subgraph. For us, this is great news!


But what does this mean for our task? Let’s break it down.


First, let’s step aside and talk about Koin scopes. As mentioned, a scope limits the lifetime of objects contained within it. Creating a scope is straightforward, but the challenge lies in closing it at the right moment — not too late to avoid memory leaks, and not too early so that dependencies remain available throughout the screen’s lifetime.


As we saw earlier, the root of the graph has its own NavBackStackEntry, which can create and manage ViewModels. We can use this ViewModel as a container for the scope. An added benefit is that the ViewModel has an onCleared method, which is an ideal place to clean up resources — in our case, to close the scope!


Phew, that covers most of the theory. Now let’s dive into the implementation.

Implementation

At first glance, it might seem simple: take the NavBackStackEntry of the subgraph’s root, create a ViewModel, and you’re done. But there’s one catch.


The NavBackStackEntry is available once the composition is created. If you look at the signature of the navigation function again, you’ll notice that it doesn’t have access to the composable context. So, what do we do?


A little trickery is required here.


The NavBackStackEntry has a destination property, which in turn has a parent property that contains a route. For example, for Screen.AuthGraph.EmailInput, the parent is Screen.AuthGraph. The catch is that route is a string, but we need to obtain the corresponding NavBackStackEntry. Fortunately, the NavController provides a function getBackStackEntry that allows us to get a NavBackStackEntry by its route — which we have.


Here’s the code to obtain the parent NavBackStackEntry:

fun NavBackStackEntry.requireParentBackStackEntry(
    navController: NavController
): NavBackStackEntry {
    val parentRoute = destination.parent?.route ?: error("current destination has no parent")
    return navController.getBackStackEntry(parentRoute)
}


Nothing complicated.


So, once we have the required NavBackStackEntry, what’s next?


Now, let’s create a container for the scope:

class ScopeViewModel(
    qualifier: Qualifier,
    scopeID: ScopeID,
) : ViewModel(), KoinComponent {

    val scope: Scope = getKoin().getOrCreateScope(scopeID, qualifier)

    override fun onCleared() {
        super.onCleared()
        scope.close()
        Log.d("ScopeViewModel", "ScopeViewModel closed, scope ${scope.id} closed")
    }
}


To create the scope, we pass in a Qualifier and a ScopeID so that Koin can create a unique scope for us. The KoinComponent interface is used to simplify access to Koin at runtime (it makes the getKoin() function available).


I’ll also create a factory for the ScopeViewModel:

class ScopeViewModelFactory(
    private val qualifier: Qualifier,
    private val scopeID: ScopeID,
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ScopeViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return ScopeViewModel(qualifier, scopeID) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}


This factory is necessary because we need to pass parameters to ScopeViewModel; otherwise, we could rely on the standard mechanism.


Let’s add a couple of helper functions that we’ll use later.

@Composable
fun rememberParentBackStackEntry(
    navController: NavController,
    backStackEntry: NavBackStackEntry,
): NavBackStackEntry {
    return remember {
        backStackEntry.requireParentBackStackEntry(navController)
    }
}


The rememberParentBackStackEntry function is used to cache the found parent NavBackStackEntry. Without this, there might be a situation where the NavController has already updated its state but the UI has not yet, leading to an exception.

fun NavBackStackEntry.getScopeViewModel(
    qualifier: Qualifier,
    scopeId: ScopeID,
): ScopeViewModel {
    return ViewModelProvider(
        this,
        ScopeViewModelFactory(
            qualifier = qualifier,
            scopeID = scopeId,
        )
    )[ScopeViewModel::class]
}


The getScopeViewModel function allows us to obtain a ScopeViewModel for a given NavBackStackEntry. This is possible because, as we discovered earlier, NavBackStackEntry implements the ViewModelStoreOwner interface.


Before moving on, let’s understand how Koin interacts with Jetpack Compose.


If you take a look at the koinViewModel function:

@Composable
inline fun <reified T : ViewModel> koinViewModel(
    qualifier: Qualifier? = null,
    viewModelStoreOwner: ViewModelStoreOwner =  LocalViewModelStoreOwner.current
        ?: error("No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"),
    key: String? = null,
    extras: CreationExtras = defaultExtras(viewModelStoreOwner),
    scope: Scope = currentKoinScope(),
    noinline parameters: ParametersDefinition? = null,
): T {
    return resolveViewModel(
        T::class, viewModelStoreOwner.viewModelStore, key, extras, qualifier, scope, parameters
    )
}


You can see that it accepts a scope parameter, which by default is obtained by calling currentKoinScope(). Let’s take a look at its code:

@Composable
@ReadOnlyComposable
//fun currentKoinScope(): Scope = LocalKoinScope.current
fun currentKoinScope(): Scope = currentComposer.run {
    try {
        consume(LocalKoinScope)
    } catch (_: UnknownKoinContext) {
        getDefaultKoinContext().let {
            warningNoContext(it)
            it.scopeRegistry.rootScope
        }
    } catch (e: ClosedScopeException) {
        getDefaultKoinContext().let {
            it.logger.debug("Try to refresh scope - fallback on default context from - $e")
            it.scopeRegistry.rootScope
        }
    }
}


Here we see that there is a LocalKoinScope. If you check the KoinAndroidContext function and then KoinContext:

@Composable
fun KoinContext(
    context: Koin = KoinPlatform.getKoin(),
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalKoinApplication provides context,
        LocalKoinScope provides context.scopeRegistry.rootScope,
        content = content
    )
}


By default, LocalKoinScope holds the root scope. For us, this means we can use the Composition Local mechanism to override LocalKoinScope when constructing our DI graph.


This is a schematic representation of our future graph. How do we build it? Let’s start by creating a feature scope. For this, I wrote another helper function:

/**
 * This function gets or creates a [ScopeViewModel] which holds a [Scope] within the [parentBackStackEntry]
 */
@Composable
inline fun <reified T : Any> ParentScopeProvider(
    parentBackStackEntry: NavBackStackEntry,
    crossinline content: @Composable () -> Unit
) {
    val parentScope = parentBackStackEntry
        .getScopeViewModel(
            qualifier = qualifier<T>(),
            scopeId = T::class.java.name,
        )
        .scope

    CompositionLocalProvider(
        LocalKoinScope provides parentScope
    ) {
        content()
    }
}


The ParentScopeProvider takes a parent NavBackStackEntry, obtains its ScopeViewModel, and overrides LocalKoinScope. The key point here is that we are reassigning LocalKoinScope.


Now, let’s modify the Content function to use the helper we just created:

@Composable
private fun Content() {

    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.AuthGraph
    ) {

        navigation<Screen.AuthGraph>(
            startDestination = Screen.AuthGraph.EmailInput
        ) {
            composable<Screen.AuthGraph.EmailInput> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    /* omitted code */
                }
            }

            composable<Screen.AuthGraph.OTP> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    /* omitted code */
                }
            }

            composable<Screen.AuthGraph.PinCode> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    /* omitted code */
                }
            }
        }
    }
}


Now that we’ve handled the parent scope, let’s move on to creating scopes for the individual screens.

/**
 * This function creates a [Scope] for a screen
 */
@OptIn(KoinExperimentalAPI::class)
@Composable
inline fun <reified T : Any> ComposeScreenScopeProvider(
    crossinline content: @Composable () -> Unit
) {
    val parentScope = LocalKoinScope.current

    val koin = getKoin()

    val scope = koin.getOrCreateScope(
        qualifier = qualifier<T>(),
        scopeId = T::class.java.name,
    )

    rememberKoinScope(
        parentScope = parentScope,
        scope = scope,
    )

    CompositionLocalProvider(
        LocalKoinScope provides scope
    ) {
        content()
    }
}


For the screen scope to work correctly and have access to the parent dependencies, we need to link the current scope with the parent. Remember, the parent scope is available in LocalKoinScope. We create the scope using getOrCreateScope from the Koin instance, and then link the two scopes using the linkTo function. The CompositionKoinScopeLoader ensures that the scope is closed at the correct moment in the composition lifecycle.

@OptIn(KoinInternalApi::class)
@KoinExperimentalAPI
@Composable
inline fun rememberKoinScope(
    parentScope: Scope?,
    scope: Scope,
): Scope {
    val wrapper = remember(scope) {
        parentScope?.let {
            scope.linkTo(parentScope)
        }

        CompositionKoinScopeLoader(scope)
    }
    return wrapper.scope
}


Let’s apply these changes to the Content function:

@Composable
private fun Content() {

    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.AuthGraph
    ) {

        navigation<Screen.AuthGraph>(
            startDestination = Screen.AuthGraph.EmailInput
        ) {
            composable<Screen.AuthGraph.EmailInput> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    ComposeScreenScopeProvider<Screen.AuthGraph.EmailInput> {
                        /* omitted code */
                    }
                }
            }

            composable<Screen.AuthGraph.OTP> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    ComposeScreenScopeProvider<Screen.AuthGraph.OTP> {
                        /* omitted code */
                    }
                }
            }

            composable<Screen.AuthGraph.PinCode> { navBackStackEntry ->
                val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)

                ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
                    ComposeScreenScopeProvider<Screen.AuthGraph.PinCode> {
                        /* omitted code */
                    }
                }
            }
        }
    }
}


And let’s not forget about the authModule:

val authModule = module {
    scope<Screen.AuthGraph> {
        scopedOf(::AuthManager)
    }
}


Conclusion

In this article, we explored how to effectively organize dependency management in a Jetpack Compose application using Koin scopes. We examined how to leverage the features of Compose Navigation—particularly nested graphs and NavBackStackEntry—to create a hierarchy of scopes and properly manage their lifecycles.


Yes, the proposed solution is not perfect, especially regarding some repetitive code, so I look forward to reading your alternative approaches or suggestions for improvement.


I hope you enjoyed this article.


If you’ve read all the way to the end, I invite you to join my YouTube and Telegram channels.


Thank you!