Navigation works fine in small demos. It breaks in real apps when routes become stringly-typed, features depend on each other, and one graph file grows forever.
A scalable setup needs three ideas:
- Nested graphs to model app sections
- Type-safe routes to remove runtime route mistakes
- Feature isolation so modules own their destinations
1) Mental model for scalable navigation
Think in layers:
- App shell graph (auth, main, onboarding)
- Feature graphs (home, profile, settings)
- Destination contracts (typed arguments, no raw string building)
This keeps navigation readable and testable when the team grows.
2) BAD vs GOOD #1 — Raw string routes everywhere
❌ BAD: route strings duplicated by hand
// BAD: Raw strings are duplicated and easy to mistype.
// A tiny typo causes runtime navigation errors.
fun NavGraphBuilder.profileScreen(navController: NavController) {
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
ProfileScreen(
userId = userId,
onBack = { navController.popBackStack() }
)
}
}
fun openProfile(navController: NavController, userId: String) {
// BAD: manual string interpolation.
// If route pattern changes, call sites silently break.
navController.navigate("profile/$userId")
}Problems:
- No compile-time safety for route/arguments
- Route format knowledge leaks into many files
- Refactoring is risky
✅ GOOD: typed destination contract + extension API
// GOOD: One object owns route template + argument keys.
// Callers use typed methods, not string concatenation.
object ProfileDestination {
const val routeBase = "profile"
const val userIdArg = "userId"
// Centralized route pattern used by NavHost.
const val routePattern = "$routeBase/{$userIdArg}"
// Typed builder used by callers.
fun createRoute(userId: String): String = "$routeBase/$userId"
}
fun NavController.navigateToProfile(userId: String) {
// Single entry point reduces duplication and mistakes.
navigate(ProfileDestination.createRoute(userId))
}
fun NavGraphBuilder.profileDestination(
onBack: () -> Unit,
) {
composable(route = ProfileDestination.routePattern) { backStackEntry ->
// Argument key re-used from contract to avoid string mismatch.
val userId = requireNotNull(
backStackEntry.arguments?.getString(ProfileDestination.userIdArg)
)
ProfileScreen(
userId = userId,
onBack = onBack,
)
}
}Why this scales better:
- Route structure changes in one place
- Safer code review (typed function calls)
- Easier migration to stronger typed-navigation APIs later
3) BAD vs GOOD #2 — One giant app graph coupled to all features
❌ BAD: app module imports and wires every screen directly
// BAD: Single giant graph in app module.
// App depends on implementation details of every feature.
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") { HomeScreen(...) }
composable("settings") { SettingsScreen(...) }
composable("profile/{userId}") { ProfileScreen(...) }
composable("notifications") { NotificationsScreen(...) }
composable("billing") { BillingScreen(...) }
// ...keeps growing forever
}
}Problems:
- Huge merge conflicts in one file
- Feature boundaries get blurred
- Hard to modularize because app module knows too much
✅ GOOD: each feature contributes its own nested graph
// GOOD: feature-home module exports only a graph registration function.
// The feature owns routes, destinations, and internal navigation.
object HomeGraph {
const val route = "home_graph"
const val start = "home"
}
fun NavGraphBuilder.homeGraph(
navController: NavController,
) {
navigation(
route = HomeGraph.route,
startDestination = HomeGraph.start,
) {
composable(HomeGraph.start) {
HomeScreen(
onOpenProfile = { userId ->
// Cross-feature navigation still uses typed API.
navController.navigateToProfile(userId)
}
)
}
}
}// GOOD: app module composes feature graphs without importing every screen.
// It coordinates sections, not details.
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "main_graph",
) {
navigation(
route = "main_graph",
startDestination = HomeGraph.route,
) {
homeGraph(navController)
profileGraph(navController)
settingsGraph(navController)
}
authGraph(navController)
}
}Benefits:
- Feature teams can evolve screens independently
- Cleaner module boundaries
- Easier testing of each graph in isolation
4) Practical checklist
- Define one destination contract per route (args + route pattern + builder)
- Expose
navigateToX()helpers from feature APIs - Keep app graph focused on section orchestration
- Put destination internals inside each feature module
- Prefer nested graphs for auth/main/settings flows
Key Takeaway: Scalable Android navigation is about architecture, not just routes. Use nested graphs for structure, typed contracts for safety, and feature-owned graphs for module isolation. Your future team (and future you) will ship faster with fewer runtime navigation bugs.