| name | type-safe-nav-graph-organization |
| description | Lay out a type-safe Navigation-Compose graph so it stays readable as the destination list grows — one serializable route object per file grouped by area, area graphs as extension functions on the graph builder, a single transition set on the host, and per-route theming applied by wrapping the screen inside its own entry. Use when a navigation file has grown to hundreds of lines, when route arguments start needing custom types, or when transitions or theming differ between destinations for no stated reason. |
Organizing a type-safe navigation graph
Type-safe routes replace string paths with serializable objects. Each one is its own file, grouped
into a package per area of the app, and holds nothing but its arguments:
@Serializable
data object HomeDestination
@Serializable
data class AlbumDestination(val browseId: String)
The host declares the transitions once and then does almost nothing else. What it registers directly
is not "whatever was left over" — it is the five bottom-bar tabs plus the fullscreen player, which
its own comment says out loud, and four of those six sit in packages that do have an area graph.
The rule is how a destination is entered — from the app chrome, or from another screen:
NavHost(
navController,
startDestination = startDestination,
enterTransition = { fadeIn() + slideInHorizontally { -it } },
exitTransition = { fadeOut() + slideOutHorizontally { it } },
popEnterTransition = { fadeIn() + slideInHorizontally { -it } },
popExitTransition = { fadeOut() + slideOutHorizontally { it } },
) {
composable<HomeDestination> { HomeScreen(navController = navController, ) }
listScreenGraph(innerPadding = innerPadding, navController = navController)
loginScreenGraph(innerPadding, navController, hideBottomBar = hideNavBar, showBottomBar = { showNavBar(false) })
}
An area graph is an ordinary extension function on the builder, reading arguments off the entry:
fun NavGraphBuilder.listScreenGraph(innerPadding: PaddingValues, navController: NavController) {
composable<AlbumDestination> { entry ->
val data = entry.toRoute<AlbumDestination>()
ForceDarkContent { AlbumScreen(browseId = data.browseId, navController = navController) }
}
}
Traps
These are not nested navigation graphs, and the difference matters. The extension functions are
a file-level grouping: each calls composable<…> directly, so every destination lands flat in the
one host graph. No navigation<…> call exists anywhere, and therefore no per-area start destination,
no nested back stack, no scoping of a view model to an area. Want any of those and you want a real
nested graph; want none of them and this buys readability with no extra semantics to reason about.
Cross-cutting values arrive as function parameters, not through navigation. Window padding, the
controller and the callbacks that hide or show the bottom bar are plain arguments on the area
function, threaded from the host — which is why the login area's signature is the longest, being the
only one needing the bar callbacks. Resist putting these on the route: a route is serialized into the
back stack and restored across process death, so anything in it must still mean something an hour
later. A lambda does not.
Theming is a property of the route, applied by wrapping the screen inside its own entry. Screens
that sit over dark artwork are wrapped where they are registered:
composable<PlaylistDestination> { entry ->
val data = entry.toRoute<PlaylistDestination>()
ForceDarkContent { PlaylistScreen(playlistId = data.playlistId, ) }
}
Wrapping the whole host instead forces the theme on every destination; putting the wrapper inside
the screen makes the screen un-reusable anywhere that does not want it. Note which destinations get
it and which do not — here the immersive list screens and the fullscreen player do, the plain list
screen beside them does not — because that asymmetry is invisible from inside the screens.
One transition set, on the host. No destination overrides it: composable<X> { … } everywhere,
never the overload taking its own enterTransition. Once one destination takes a private transition
the next reviewer cannot tell whether the rest were deliberate or forgotten, so say why at any override.
Argument types stay serializable primitives; conversion is documented at the route. A route
carrying what is conceptually an enum stores its string form and names the converter in KDoc, rather
than registering a custom NavType:
@Serializable
data class LibraryDynamicPlaylistDestination(val type: String)
Constants that belong to a route live in that route's own companion object, next to the parameter
they constrain, rather than in a shared constants file every screen imports.
A route may deliberately carry nothing, and that has to be written down. One login route is an
argument-less object whose KDoc explains that the callback token reaches a view model instead —
routing it would push a second copy of the login screen over the one the user started from.
Without that note the empty route reads as an oversight and the next person adds the argument back.
Deep links are not on the destinations here. No composable<…>(deepLinks = …) exists; incoming
links are parsed outside the graph and dispatched as an app-level intent. A fork in the road, not a
default — deciding it explicitly is what stops half the links living on routes and half in a view model.
A route with no entry compiles. Nothing links a route to the graph until a composable<…> block
names it, so a destination file plus a navigation call builds clean and fails at the tap — step 2.
Verifying it
find . -path "*/navigation/destination/*" -name "*.kt" -not -path "*/build/*" -exec grep -L "@Serializable" {} +
comm -23 \
<(find . -path "*/navigation/destination/*" -name "*.kt" -not -path "*/build/*" -exec sed -n 's/^\(data \)\?\(class\|object\) \([A-Za-z0-9_]*\).*/\3/p' {} + | sort -u) \
<(grep -rho --include="*.kt" "composable<[A-Za-z0-9_]*>" . | sed 's/composable<\(.*\)>/\1/' | sort -u)
grep -rn --include="*.kt" "fun NavGraphBuilder\." . | grep -v "/build/"
grep -rn --include="*.kt" "NavHost(" . | grep -v "/build/"
grep -rn --include="*.kt" -E "composable<[A-Za-z0-9_]*> *\(" . | grep -v "/build/"
Then navigate deep, background the app, let the process be killed, and return: the back stack is
rebuilt from the serialized routes, so an argument that was not really a value surfaces here and
nowhere earlier. Watch the transitions in and out of one destination per area — a route that
animates differently from its neighbours is an override step 4 missed.