“External” navigation in a multi-module project in Kotlin

Doubletapp
9 min readNov 20, 2023

Hello, i’m Dmitry Voronov from Doubletapp, and in this article, I will tell you how we implemented navigation in Yandex Travel. When it comes to navigation in Android, it seems like everything is clear: use Jetpack Navigation, read the official documentation, follow it, and everything will work out. If the recommended library doesn’t fit, you can use Fragment Manager, implement your own solution, and show off to your colleagues. If you don’t want to write your own implementation and the official library doesn’t match the latest trends — enhance your resume by showcasing your ability to work with Cicerone. And if you have specific tastes, why not surprise people with an unexpected addition of Alligator to the project?

Apple Stor
Google Play

In one short paragraph, we managed to outline 4 different navigation implementation options. And you might think, what’s the question? Everyone chooses the option that suits their project. That’s true, but only until the need arises to “share” a part of the application — integrate it into another application where there’s a different navigation implementation. And that’s when the question arises: “What should we do? Should we try to write a bridge? Or maybe it’s better to rewrite the navigation?”

During the design phase, it’s better to clarify the possibility of future integration with other applications and prepare for it in advance. In addition to everything else, you need to prepare the navigation and make it “external” — one of the possible solutions to this task.

What is “external” navigation?

If we are developing a multi-module application, we face the question of how to organize communication between these modules, and it seems clear: no matter how the features are divided into modules, they will have some API (one or more) and its implementation (again, one or more). However, there is a question: should navigation be part of this API? The answer is simple: no, it shouldn’t. After all, then the feature itself would have to handle navigation, which violates the principles of single responsibility and encapsulation. The responsibility for implementing navigation should lie with a separate or dedicated part of the application, and the feature should only have the ability to “declare” that it intends to navigate to another feature. Hence, it is easy to conclude that navigation should be an external dependency of the feature, and the navigation feature — a separate module of the application, which we will call “external” navigation.

Instead of implementing a specific navigation mechanism within each feature, we should provide dependencies to the features that implement navigation. This way, each feature will be abstracted from the specific navigation implementation, which will allow easy integration into other applications in the future.

Of course, integrating your modules somewhere else will only be easy if you follow many other rules in addition to abstracting navigation. However, a full description of all the nuances would require a separate series of articles, so within the scope of this article, we will only consider navigation.

So, we have covered the introductory information, and now it’s time to look at the implementation details.

Implementing “external” navigation

So, you have found out that the application features may be integrated into another service in the future. Your task is to minimize the coupling and dependency between the modules as much as possible. In addition to everything else, we will solve the navigation problem by encapsulating it behind “clean” interfaces, so that when moving modules anywhere; you only need to implement the interfaces according to your needs. Before we start implementing and writing code, let’s outline and formulate the upcoming solution. We will consider the implementation using a classic Android application as an example. Below is a diagram of the solution, let’s go through it.

Note: The arrows in the diagram indicate module dependencies (they go from the dependent module to the module it depends on). At the top, we see the familiar app module. Let’s assume we have some hypothetical Feature 1, Feature 2, and Feature 3, which are connected to the app module. These will be 3 screens representing a certain flow (for example, it could be an authentication flow or any other). In the diagram, the features are represented as solid blocks, but here we should note that features can be organized as truly independent modules or as a combination of modules: for example, as api and impl modules, or as a combination of several domain, data, and presentation modules according to clean architecture. For now, this is not important for us; we will consider these features as abstract concepts.

What is really important for us regarding these features is that they have external dependencies, and one of these dependencies is the navigation interface. It is located in the navigation-api module, which is referenced by all features. This is a pure Kotlin module that declares the following abstraction:

interface NavigationApi <DIRECTION> {
fun navigate(direction: DIRECTION)
}

We supply this interface to the features. Each feature declares public abstractions corresponding to possible “directions” in which this feature can navigate as generics for DIRECTION. Let’s assume, for example, that Feature 1 can navigate to Feature 2. Then its “directions” can be represented by the following abstraction:

sealed interface Feature1Directions {
object ToFeature2 : Feature1Directions
}

Then the external dependencies for Feature 1 will look like this:

interface Feature1Dependencies {
val navigationApi: NavigationApi<Feature1Directions>
// Other dependencies ...
}

In turn, Feature 2 can navigate to Feature 3 and perform the “back” action. Then its navigation directions will look like this:

sealed interface Feature2Directions {
object Up : Feature2Directions
data class ToFeature3(val args: Feature2To3Args) : Feature2Directions
}
data class Feature2To3Args(
val someArg1: Int,
val someArg2: String,
)

And finally, let the third feature perform the “back” action and return to the first feature, i.e., reset the entire flow of three screens to the initial state — the first screen. Then its navigation directions will look like this:

sealed interface Feature3Directions {
object Up : Feature3Directions
object UpToFeature1 : Feature3Directions
}

Below is a diagram of the interaction between screens that we have just described:

At this stage, we will finish discussing the features, as we have already described everything related to them. All that remains to be done within these features is to perform navigation by calling a method from the navigation interface, passing the appropriate direction to it, for example, like this:

fun someFunction() {
// Previous code ...
navigationApi.navigate(Feature1Directions.ToFeature2)
}

Let’s implement the navigation module

We’ll start with something simple and straightforward: organizing dependencies. It’s clear that the navigation module, in order to facilitate interaction between features, should reference the feature modules and the navigation API. And to include the navigation module in the application, the app module should reference it. We’ve got that covered.

As mentioned earlier, any mechanism can be chosen for navigation. For simplicity, we’ll use the standard approach: the official Jetpack Navigation library. Here’s how it works: we create a navigation graph, define transitions between screens, specify arguments, and for convenience, we immediately integrate Safe Args. Since the external navigation module includes the implementation modules of specific features (see the dependency diagram above), we have access to the fragments in this module, from which we’ll create the familiar Destinations.

Next, we need to implement the navigation interfaces for the features we declared earlier. Let’s take the navigation implementation for the second feature as an example:

internal class Feature2NavigationImpl @Inject constructor(
private val navController: Provider<NavController>,
): NavigationApi<Feature2Directions> {

override fun navigate(direction: Feature2Directions) {
when (direction) {
is Feature2Directions.ToFeature3 -> {
navController.get().navigate(
Feature2FragmentDirections.fromFeature2ToFeature3(
args = direction.args.toFeature3Args(),
)
)
}
is Feature2Directions.Up -> {
navController.get().navigateUp()
}
}
}

companion object {
private fun Feature2To3Args.toFeature3Args(): Feature3Args = Feature3Args(
value = "$someArg2 : $someArg1"
)
}
}

In the overridden navigate() method, we iterate through each direction and perform the corresponding action. For the Up direction, we simply call the familiar navigateUp() on the NavController, and for the ToFeature3 direction, we use navigate().

The extension function toFeature3Args() for the arguments is interesting. It means that the second feature passes its model with arguments (Feature2To3Args) to the ToFeature3 direction, and the function maps the arguments to the model from feature 3 (Feature3Args). This way, the features remain as independent as possible from each other.

As you can see, it’s not complicated: we iterate through all the directions, map the arguments, make the corresponding NavController calls, and inject the resulting class into the component if you’re using dependency injection, as in this example. In this case, the most interesting part is how to pass the navigation mechanism abstraction (or implementation) to the constructor. Let’s figure it out.

In any case, the navigation abstraction should be taken from the presentation layer: fragments, activities. In Android, views have a “lifecycle” — they can’t be injected just like that, but there are ways to do it. So, fragments and activities have the property of being recreated, which means that simply passing an instance won’t work; we need to pass a “way to obtain” it. That’s why in the example above, the NavController is not directly injected into the constructor — Provider is used instead. Where can the provider get the NavController from? From the fragment, which is located in the navigation module and contains NavHost. Or from the activity, which contains NavHost, but in our case, we’re moving the navigation to a separate module, and we have a Single Activity setup, so we’ll focus on the fragment.

So, in the app module, we have MainActivity, which displays NavigationFragment, which resides in the navigation module and contains NavHost. Here’s the diagram:

From here, it follows that to access navigation from NavHost, we need to provide a source, i.e., the activity. First, let’s declare an interface in the navigation module:

interface NavigationActivity {

fun getNavigationFragment(): NavigationFragment?
}

This interface allows us to access the navigation fragment, and it will be implemented by MainActivity as follows:

override fun getNavigationFragment(): NavigationFragment? = supportFragmentManager.fragments
.filterIsInstance<NavigationFragment>()
.firstOrNull()

Now we can get the navigation fragment from the activity. We need to find a way to get the activity itself. This can be done using Application.ActivityLifecycleCallbacks. As an external dependency for the navigation module, let’s declare the following class:

class NavigationActivityProvider(application: Application) {

private var activityReference: WeakReference<NavigationActivity>? = null

fun get(): NavigationActivity? = activityReference?.get()

init {
registerActivityCallbacks(application)
}

private fun registerActivityCallbacks(application: Application) {
application.registerActivityLifecycleCallbacks(
object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, p1: Bundle?) {
if (activity is NavigationActivity) {
activityReference = WeakReference(activity)
}
}

override fun onActivityDestroyed(activity: Activity) {
if (activity is NavigationActivity) {
activityReference = null
}
}


}
)
}
}

Then, by passing the Application to the constructor, we can access NavigationActivity in the navigation module, and from it, we can access NavigationFragment, and from NavigationFragment, we can directly access NavController, like this:

class NavigationFragment : Fragment() { 

val navController: NavController by lazy {
(childFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment).navController
}


}

All that’s left is to provide this “access method” in the navigation module:

@Module
internal class NavigationImplModule {

@Provides
fun provideNavController(
activityProvider: NavigationActivityProvider,
): NavController = activityProvider.get()
?.getNavigationFragment()
?.navController
?: error("Do not make navigation calls while activity is not available")
}

That’s it. We can now use this implementation in the NavigationApi for different features!

A small note: if you still need to implement “internal” navigation within a specific feature module, you can modify or extend the approach to fit your needs.

In conclusion

Seeing once is better than hearing a hundred times, so here you can find an example of the “external” navigation described in the article. It contains only what’s necessary, but it includes dependency injection implemented with Dagger 2 using Component Holders. The base classes for this can be found in the di module — it’s just one of the ways to organize dependencies in a multi-module project. You can find something similar in this article. For more details, you can read about the case on our website or download the iOS or Android app and test it yourself. If you have any questions or want to share what navigation you use in your projects, let’s discuss it in the comments.

--

--