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
| Pattern | Best for |
|---|---|
| Interface contracts | Clean DI-driven apps; easy to test |
| Navigation Component sub-graphs | Apps that already use Navigation Component |
| Deep links | Loose coupling; external navigation (push notifications) |
| Activity-based | Fully independent features with separate back stacks |