Back to Notes

Scalable Navigation Architecture: Nested Graphs, Type-Safe Routes, and Feature Isolation

By Davide Agostini

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:

  1. App shell graph (auth, main, onboarding)
  2. Feature graphs (home, profile, settings)
  3. 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.