androidengineers.Book a session

App Modularization

Multi-module Navigation Patterns

article20 minHard

Navigation between feature modules is tricky: module B can't import Activity or Fragment classes from module A (that would create a dependency cycle). Several patterns solve this, each with different trade-offs.

The Problem

:feature-feed wants to navigate to :feature-profile
          ↓
If :feature-feed imports ProfileActivity directly:
    :feature-feed → :feature-profile ← :app → :feature-feed = CYCLE

Pattern 1: Navigation via Interfaces (Contract Pattern)

Define navigation contracts in a :core-navigation module that both features depend on:

// :core-navigation module
interface NavigationController {
    fun navigateToProfile(userId: String)
    fun navigateToArticleDetail(articleId: String)
    fun navigateToSettings()
}
// :feature-feed uses the interface, never ProfileActivity directly
class FeedFragment : Fragment() {
    private val navController: NavigationController by inject()

    fun onUserTapped(userId: String) {
        navController.navigateToProfile(userId)
    }
}

// :app implements NavigationController and registers it in DI
class AppNavigationController(
    private val activity: MainActivity
) : NavigationController {
    override fun navigateToProfile(userId: String) {
        activity.findNavController(R.id.nav_host).navigate(
            ProfileFragmentDirections.actionGlobalProfile(userId)
        )
    }
}

Pros: Clean separation; easy to mock in tests. Cons: Requires DI setup; can feel verbose.

Pattern 2: Jetpack Navigation with Global Actions

Use a single nav_graph.xml in :app that includes sub-graphs from each module. Global actions let any destination navigate to any other:

<!-- :app/res/navigation/nav_graph.xml -->
<navigation
    android:id="@+id/nav_graph"
    app:startDestination="@id/feedFragment">

    <include app:graph="@navigation/feed_nav_graph" />
    <include app:graph="@navigation/profile_nav_graph" />

    <!-- Global action: any destination can navigate to profile -->
    <action
        android:id="@+id/action_global_profile"
        app:destination="@id/profileFragment">
        <argument android:name="userId" app:argType="string" />
    </action>
</navigation>
// In :feature-feed — navigate without importing from :feature-profile
findNavController().navigate(
    NavDeepLinkRequest.Builder
        .fromUri("android-app://com.example/profile/$userId".toUri())
        .build()
)

Pros: Leverages standard Navigation Component; no extra interfaces. Cons: Deep links string-based — typos become runtime crashes.

Pattern 3: Deep Links

Each feature module registers its own deep links. Navigation is string-based:

<!-- :feature-profile/res/navigation/profile_nav_graph.xml -->
<fragment android:id="@+id/profileFragment">
    <deepLink app:uri="android-app://com.example/profile/{userId}" />
</fragment>
// Navigate from any module
findNavController().navigate("android-app://com.example/profile/$userId".toUri())

Pros: Zero coupling between modules. Cons: No compile-time safety; easy to introduce broken links.

Pattern 4: Activity-Level Navigation (Hybrid)

For large-scale apps where features are entirely separate screens, each feature is its own Activity:

// :core-navigation — intent factories
object Destinations {
    fun profileIntent(context: Context, userId: String) = Intent(
        context,
        Class.forName("com.example.profile.ProfileActivity")  // reflection avoids import
    ).apply { putExtra("user_id", userId) }
}

// Or better — use explicit routing via a constant
const val PROFILE_ACTIVITY = "com.example.profile.ProfileActivity"

Pros: Each feature is fully independent; different back stacks. Cons: Activity restart overhead; data passing via Intents is cumbersome.

Recommended Pattern for Most Apps

Small-medium app (1–3 teams):
  → Navigation Component with included sub-graphs + global actions

Large app (4+ feature teams):
  → Interfaces in :core-navigation + single host NavController in :app

Hybrid modular + multi-process:
  → Deep links + explicit Activity intents

Testing Navigation in Isolation

// Test navigation without depending on other feature modules
@Test
fun `tap user avatar navigates to profile`() {
    val mockNavController = mock<NavigationController>()
    launchFragmentInContainer<FeedFragment> {
        (this as HasFeedDependencies).navController = mockNavController
    }

    onView(withId(R.id.userAvatar)).perform(click())

    verify(mockNavController).navigateToProfile("user123")
}

Key Takeaways

PatternBest for
Interface contractsClean DI-driven apps; easy to test
Navigation Component sub-graphsApps that already use Navigation Component
Deep linksLoose coupling; external navigation (push notifications)
Activity-basedFully independent features with separate back stacks

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Multi-module Navigation Patterns | Android System Design | Android Engineers