Navigation Across Modules
Connect features without coupling.
Navigation Across Modules is a free Android Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Android Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
The Navigation Challenge
Once features live in separate modules, a new question appears: how does :feature:home open a screen in :feature:profile without depending on it? If features import each other directly, you get coupling and risk cycles.
In this lesson you will connect features through navigation while keeping them independent.
Where the NavHost Lives
The single NavHost lives in the :app module, the one place that is allowed to know about every feature. Each feature contributes its destinations, and :app assembles them into one graph.
// app/AppNavHost.kt
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController, startDestination = HomeRoute) {
homeScreen(onProfileClick = { navController.navigate(ProfileRoute) })
profileScreen(onBack = { navController.popBackStack() })
}
}Type-Safe Routes
Modern Navigation Compose supports type-safe routes: a route is a @Serializable object or data class, not a magic string. Each feature defines its own route type in its module so it owns its navigation contract.
// feature/profile/ProfileRoute.kt
import kotlinx.serialization.Serializable
@Serializable
data class ProfileRoute(val userId: String)Features Expose NavGraphBuilder Extensions
The key trick: each feature exposes a NavGraphBuilder extension function that registers its destination. The feature owns its screen; :app just calls this function. The feature never references other features.
// feature/profile/ProfileNavigation.kt
fun NavGraphBuilder.profileScreen(onBack: () -> Unit) {
composable<ProfileRoute> { backStackEntry ->
val route: ProfileRoute = backStackEntry.toRoute()
ProfileScreen(userId = route.userId, onBack = onBack)
}
}Decoupling with Navigation Callbacks
A feature must never call navigate(SomeOtherFeatureRoute) directly, because that would require depending on the other feature. Instead, the feature exposes lambda callbacks like onProfileClick. The :app module decides where they actually go.
// feature/home/HomeNavigation.kt
fun NavGraphBuilder.homeScreen(onProfileClick: (String) -> Unit) {
composable<HomeRoute> {
HomeScreen(onUserClick = { userId -> onProfileClick(userId) })
}
}
// :home does NOT know ProfileRoute exists.The :app Module Wires It All
Only the :app module knows about every route and connects the callbacks to real navigation. This is the one place coupling is acceptable — its whole job is assembly.
// app/AppNavHost.kt
NavHost(navController, startDestination = HomeRoute) {
homeScreen(
onProfileClick = { userId ->
navController.navigate(ProfileRoute(userId)) // app knows both
}
)
profileScreen(onBack = { navController.popBackStack() })
}Passing Arguments Safely
Because routes are @Serializable data classes, arguments are type-checked at compile time. You read them back with toRoute() inside the destination. No more string parsing or runtime arguments?.getString(...) crashes.
// Reading arguments inside the destination
composable<ProfileRoute> { entry ->
val args: ProfileRoute = entry.toRoute()
ProfileScreen(userId = args.userId)
}
// Or directly in a ViewModel via SavedStateHandle
val route: ProfileRoute = savedStateHandle.toRoute()Sharing Route Types via an api Module
Sometimes one feature genuinely needs to navigate to another and wants the route type. Rather than depending on the whole feature, expose just the route in a tiny :feature:profile:api module containing only the @Serializable route. The heavy :impl module stays private.
// feature/profile/api -> only the route type
@Serializable
data class ProfileRoute(val userId: String)
// feature/home/build.gradle.kts
// home may depend on the lightweight api to build the route,
// but never on :feature:profile:impl
implementation(project(":feature:profile:api"))Nested Navigation per Feature
A feature with several screens can expose a whole nested graph using navigation<T>. The feature owns its internal flow; :app just mounts the graph at one entry point.
// feature/onboarding/OnboardingNavigation.kt
fun NavGraphBuilder.onboardingGraph(onFinished: () -> Unit) {
navigation<OnboardingGraph>(startDestination = WelcomeRoute) {
composable<WelcomeRoute> { WelcomeScreen() }
composable<PermissionsRoute> { PermissionsScreen(onDone = onFinished) }
}
}Deep Links Across Modules
Type-safe routes also support deep links. A feature declares a deep-link URI pattern for its destination; the :app NavHost resolves an incoming link to the right feature screen, no cross-feature import needed.
composable<ProfileRoute>(
deepLinks = listOf(
navDeepLink<ProfileRoute>(basePath = "https://myapp.com/profile")
)
) { entry ->
ProfileScreen(userId = entry.toRoute<ProfileRoute>().userId)
}The Decoupling Pattern Summarized
Put together, the rules are simple:
- Each feature owns its route type and a NavGraphBuilder extension.
- Features communicate intent through lambda callbacks, not direct navigation.
- Only :app knows all features and wires callbacks to real routes.
- If a route must be shared, expose it via a tiny :api module.
This keeps features independent, build-cacheable, and free of cycles.
Quick Check
In a multi-module app, :feature:home needs to send the user to a screen in :feature:profile. What is the cleanest way to keep the features decoupled?
Recap: Navigation Across Modules
You learned to connect features without coupling them:
- The single
NavHostlives in the thin :app module. - Each feature owns a
@Serializableroute and aNavGraphBuilderextension. - Features expose callbacks instead of navigating to other features directly.
- Share a route only through a small :api module when truly needed; deep links and nested graphs fit the same pattern.
That completes Multi-Module App Architecture: you can now split, wire, and navigate a scalable Android codebase.
Frequently asked questions
Is the “Navigation Across Modules” lesson free?
Yes — the full text of “Navigation Across Modules” is free to read here on the web, and the Android Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Android Academy course, upgrade to CoddyKit PRO.
What will I learn in “Navigation Across Modules”?
Connect features without coupling. You practise Android Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Android Academy?
No prior experience is required. Android Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Navigation Across Modules” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Android Academy lesson?
Yes. Every Android Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Why Modularize
- Feature and Core Modules
- Managing Module Dependencies
- Navigation Across Modules