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



Beyond Monolith: Mastering Modular Navigation in Android (Kotlin & Jetpack Nav Component)


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.

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.

Module Structure Example

Press enter or click to view image in full size
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>

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.

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.

Kotlin Example: Deep Link Navigation

  1. 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
}

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.

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).

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.

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.

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:

  1. 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.
  2. 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.

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! 🙏

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Code Generation vs. Reflection: A Build-Time Reliability Analysis