Beyond Monolith: Mastering Modular Navigation in Android (Kotlin & Jetpack Nav Component)
Decouple Your Android App: Using Deep Links and DI to Build Scalable Navigation with Kotlin
Introduction: Why Your Navigation Needs Modularization
As Android applications grows, the main app module becomes a bloated monolith. This affects build times, limits parallel development, and turns your main navigation graph (nav_graph.xml) into an unreadable tangle.
The solution? Modularization.
By splitting your app into decoupled feature modules (e.g., :feature:profile, :feature:settings), you gain massive benefits:
- Faster Builds: Gradle can parallelize building smaller modules.
- Decoupling: Modules don’t know (or care) about each other’s implementation details.
- Clear Ownership: Teams own their entire feature, including its navigation flow.
The key challenge is navigating between these independent features without creating circular or unwanted dependencies. The Jetpack Navigation Component, paired with a few smart techniques, makes this surprisingly clean.
As Android applications grows, the main app module becomes a bloated monolith. This affects build times, limits parallel development, and turns your main navigation graph (nav_graph.xml) into an unreadable tangle.
The solution? Modularization.
By splitting your app into decoupled feature modules (e.g., :feature:profile, :feature:settings), you gain massive benefits:
- Faster Builds: Gradle can parallelize building smaller modules.
- Decoupling: Modules don’t know (or care) about each other’s implementation details.
- Clear Ownership: Teams own their entire feature, including its navigation flow.
The key challenge is navigating between these independent features without creating circular or unwanted dependencies. The Jetpack Navigation Component, paired with a few smart techniques, makes this surprisingly clean.
1. The Foundation: Nested Graphs (<include>)
For internal feature flow, the simplest form of modularity is using nested navigation graphs. You keep all the screens related to a feature inside that feature module and reference its graph from the main app’s navigation graph.
For internal feature flow, the simplest form of modularity is using nested navigation graphs. You keep all the screens related to a feature inside that feature module and reference its graph from the main app’s navigation graph.
Module Structure Example
Kotlin Example: Including a Feature Graph
The :feature:profile module exposes a self-contained graph like this (in profile_nav_graph.xml):
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/profile_nav_graph"
app:startDestination="@id/profileStartFragment">
<fragment
android:id="@+id/profileStartFragment"
android:name="com.example.profile.ProfileStartFragment"
android:label="ProfileStart" />
<fragment
android:id="@+id/editProfileFragment"
android:name="com.example.profile.EditProfileFragment"
android:label="EditProfile" />
<action
android:id="@+id/action_to_edit_profile"
app:destination="@id/editProfileFragment" />
</navigation>The main :app module simply includes this graph in its main_nav_graph.xml:
<?xml version="1.0" encoding="utf-8"?>
<navigation ... >
<include app:graph="@navigation/profile_nav_graph" />
<fragment
android:id="@+id/homeFragment"
android:name="com.example.app.HomeFragment"
android:label="Home" >
<action
android:id="@+id/action_to_profile"
app:destination="@id/profile_nav_graph" />
</fragment>
</navigation>
The :feature:profile module exposes a self-contained graph like this (in profile_nav_graph.xml):
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/profile_nav_graph"
app:startDestination="@id/profileStartFragment">
<fragment
android:id="@+id/profileStartFragment"
android:name="com.example.profile.ProfileStartFragment"
android:label="ProfileStart" />
<fragment
android:id="@+id/editProfileFragment"
android:name="com.example.profile.EditProfileFragment"
android:label="EditProfile" />
<action
android:id="@+id/action_to_edit_profile"
app:destination="@id/editProfileFragment" />
</navigation>The main :app module simply includes this graph in its main_nav_graph.xml:
<?xml version="1.0" encoding="utf-8"?>
<navigation ... >
<include app:graph="@navigation/profile_nav_graph" />
<fragment
android:id="@+id/homeFragment"
android:name="com.example.app.HomeFragment"
android:label="Home" >
<action
android:id="@+id/action_to_profile"
app:destination="@id/profile_nav_graph" />
</fragment>
</navigation>2. True Decoupling: Navigating via Deep Links (The API Contract)
Nested graphs work well, but they require the main :app module to have a dependency on all feature modules. What if :feature:home needs to navigate to :feature:settings without having a direct dependency?
The answer is Deep Links. We treat a Deep Link URI as the public API contract for a destination, allowing modules to communicate without being coupled to resource IDs.
Nested graphs work well, but they require the main :app module to have a dependency on all feature modules. What if :feature:home needs to navigate to :feature:settings without having a direct dependency?
The answer is Deep Links. We treat a Deep Link URI as the public API contract for a destination, allowing modules to communicate without being coupled to resource IDs.
Important Clarification: NavController Scope
When implementing feature flows:
Each feature module may host its own NavController if it contains multiple internal screens. This involves using a nested NavHostFragment or NavHost (for Compose) within that feature's UI. Alternatively, all destinations can remain under a single global NavController if the feature is assembled as simple destinations in the main graph.
When implementing feature flows:
Each feature module may host its own NavController if it contains multiple internal screens. This involves using a nested
NavHostFragmentorNavHost(for Compose) within that feature's UI. Alternatively, all destinations can remain under a single globalNavControllerif the feature is assembled as simple destinations in the main graph.
Kotlin Example: Deep Link Navigation
- Define the Contract (in the target module’s XML)
- In
:feature:settings's settings_nav_graph.xml, define a deep link that acts as a public entry point:
<fragment
android:id="@+id/accountSettingsFragment"
android:name="com.example.settings.AccountSettingsFragment"
android:label="AccountSettings">
<deepLink
android:id="@+id/deep_link_to_settings"
app:uri="myapp://features/account_settings/{userId}" />
<argument
android:name="userId"
app:argType="string" />
</fragment>⚠️ Note on Host Definition: For this deep link to work within the app, the target destination (or its containing graph) must be included in the assembled global navigation graph that the main NavHost is using.
2. Navigate (from the source module’s Kotlin)
- In a
ViewModel or Fragment within the decoupled :feature:home module, we navigate using the URI:
// This function is inside a component of the independent :feature:home module
fun navigateToSettings(userId: String, navController: NavController) {
// 1. Construct the URI using the agreed-upon public contract
val deepLinkUri = "myapp://features/account_settings/$userId".toUri()
// 2. Create the NavDeepLinkRequest
val request = NavDeepLinkRequest.Builder
.fromUri(deepLinkUri)
.build()
// 3. Navigate using the request (no resource ID knowledge required!)
navController.navigate(request)
// We successfully jumped from :feature:home to :feature:settings
// without :feature:home depending on :feature:settings's R.id
}
- Define the Contract (in the target module’s XML)
- In
:feature:settings'ssettings_nav_graph.xml, define a deep link that acts as a public entry point:
<fragment
android:id="@+id/accountSettingsFragment"
android:name="com.example.settings.AccountSettingsFragment"
android:label="AccountSettings">
<deepLink
android:id="@+id/deep_link_to_settings"
app:uri="myapp://features/account_settings/{userId}" />
<argument
android:name="userId"
app:argType="string" />
</fragment>⚠️ Note on Host Definition: For this deep link to work within the app, the target destination (or its containing graph) must be included in the assembled global navigation graph that the main NavHost is using.
2. Navigate (from the source module’s Kotlin)
- In a
ViewModelorFragmentwithin the decoupled:feature:homemodule, we navigate using the URI:
// This function is inside a component of the independent :feature:home module
fun navigateToSettings(userId: String, navController: NavController) {
// 1. Construct the URI using the agreed-upon public contract
val deepLinkUri = "myapp://features/account_settings/$userId".toUri()
// 2. Create the NavDeepLinkRequest
val request = NavDeepLinkRequest.Builder
.fromUri(deepLinkUri)
.build()
// 3. Navigate using the request (no resource ID knowledge required!)
navController.navigate(request)
// We successfully jumped from :feature:home to :feature:settings
// without :feature:home depending on :feature:settings's R.id
}3. Deep Link Arguments and Type-Safe Modular Navigation
While Deep Links decouple modules perfectly, a common misconception is around data passing.
Deep Link arguments support more than strings. The Navigation Component supports passing many primitive types directly through URI arguments, including strings, ints, longs, floats, and booleans.
To achieve true type-safety across modules when using deep links:
- Centralize URIs: Define all inter-module deep link URIs in a shared
:core:navigation module as constants. - Wrap Navigation: Create a
Navigator interface (or a sealed class of Directions) in the shared module. Your feature modules call this high-level abstraction, and the :app module provides the implementation that handles the URI construction and navController.navigate(). This effectively brings back type-safety at the call site.
While Deep Links decouple modules perfectly, a common misconception is around data passing.
Deep Link arguments support more than strings. The Navigation Component supports passing many primitive types directly through URI arguments, including strings, ints, longs, floats, and booleans.
To achieve true type-safety across modules when using deep links:
- Centralize URIs: Define all inter-module deep link URIs in a shared
:core:navigationmodule as constants. - Wrap Navigation: Create a
Navigatorinterface (or a sealed class ofDirections) in the shared module. Your feature modules call this high-level abstraction, and the:appmodule provides the implementation that handles the URI construction andnavController.navigate(). This effectively brings back type-safety at the call site.
4. The Next Frontier: Dynamic Assembly via Dependency Injection (Advanced — Compose Focus)
The core idea for the most scalable architecture is: Don’t hardcode the graph assembly in XML.
Instead of the main :app module manually including every graph, each feature module is responsible for exposing its destinations (its entry builders or routes) to the main app via Dependency Injection (DI), typically using Hilt multibindings (@IntoSet).
The core idea for the most scalable architecture is: Don’t hardcode the graph assembly in XML.
Instead of the main :app module manually including every graph, each feature module is responsible for exposing its destinations (its entry builders or routes) to the main app via Dependency Injection (DI), typically using Hilt multibindings (@IntoSet).
XML vs. Compose Implementation
- XML Navigation: Full dynamic runtime graph construction using DI is severely limited or impossible with XML-based Jetpack Navigation. You are generally restricted to static
<include> or Deep Link resolution. - Navigation Compose (and Navigation 3.x): This pattern shines brightest here. Each feature module registers its composable destinations dynamically, making the architecture highly flexible and avoiding large, monolithic graph definitions.
- XML Navigation: Full dynamic runtime graph construction using DI is severely limited or impossible with XML-based Jetpack Navigation. You are generally restricted to static
<include>or Deep Link resolution. - Navigation Compose (and Navigation 3.x): This pattern shines brightest here. Each feature module registers its composable destinations dynamically, making the architecture highly flexible and avoiding large, monolithic graph definitions.
The API/Impl Split Reimagined (Compose/Navigation 3.x)
:feature:settings:api: Contains only the high-level NavKey or Route object (the navigation contract).:feature:settings:impl: Contains the actual Compose Screen implementation and the Dagger/Hilt code that registers this screen's builder function with the main graph's assembly set.
The :app module then uses DI to collect all these registered components into a Set and dynamically builds the complete navigation graph at runtime. This creates the most scalable, decoupled, and testable navigation architecture possible.
:feature:settings:api: Contains only the high-levelNavKeyorRouteobject (the navigation contract).:feature:settings:impl: Contains the actual Compose Screen implementation and the Dagger/Hilt code that registers this screen's builder function with the main graph's assembly set.
The :app module then uses DI to collect all these registered components into a Set and dynamically builds the complete navigation graph at runtime. This creates the most scalable, decoupled, and testable navigation architecture possible.
Frequently Asked Questions (FAQs)
What is the main difference between an include graph and a Deep Link?
A nested graph using <include> couples the main graph to the feature module’s navigation graph file, requiring the main app to know about that file. A Deep Link, however, decouples the modules entirely, allowing one module to navigate to another without a compile-time dependency on its destination ID; it only needs to know the URI string.
What if I need to pass complex objects between modules?
Passing large or complex objects via Deep Link arguments (which are restricted to simple primitives or strings) is strongly discouraged. For complex data:
- Pass ID only: Pass a simple
id via the deep link, then use a shared data source (like a Repository or a shared ViewModel) to retrieve the complex object in the destination module. - Use a shared module: Place the complex
Parcelable or Serializable data class in a common :core:model module that both feature modules can depend on.
What is the main difference between an include graph and a Deep Link?
A nested graph using <include> couples the main graph to the feature module’s navigation graph file, requiring the main app to know about that file. A Deep Link, however, decouples the modules entirely, allowing one module to navigate to another without a compile-time dependency on its destination ID; it only needs to know the URI string.
What if I need to pass complex objects between modules?
Passing large or complex objects via Deep Link arguments (which are restricted to simple primitives or strings) is strongly discouraged. For complex data:
- Pass ID only: Pass a simple
idvia the deep link, then use a shared data source (like a Repository or a sharedViewModel) to retrieve the complex object in the destination module. - Use a shared module: Place the complex
ParcelableorSerializabledata class in a common:core:modelmodule that both feature modules can depend on.
Your Turn, Reader!
Modular navigation is a journey. What challenges are you currently facing in your app architecture?
- Do you prefer Deep Links or a custom Navigation Router interface for inter-module communication?
- How do you manage complex data passing between decoupled feature modules?
Share your thoughts in the comments below!
For a visual demonstration of how to implement deep links for navigation across decoupled Gradle modules, this video provides a great, quick tip: Deep-link Navigation tip.
If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! đ
Modular navigation is a journey. What challenges are you currently facing in your app architecture?
- Do you prefer Deep Links or a custom Navigation Router interface for inter-module communication?
- How do you manage complex data passing between decoupled feature modules?
Share your thoughts in the comments below!
For a visual demonstration of how to implement deep links for navigation across decoupled Gradle modules, this video provides a great, quick tip: Deep-link Navigation tip.
If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! đ


Comments
Post a Comment