Skip to content
Muhammed Ali
Kocabey

Generating Android Navigation Graphs with KSP

If you want a type-safe navigation graph that stays in sync with your screens, you can generate it at compile time: write a Kotlin Symbol Processing (KSP) SymbolProcessor that reads a @Destination annotation on each screen, collects those declarations across the module, and emits one file containing a NavGraphBuilder extension and the route helpers that go with it. Adding a screen then becomes a single annotation instead of three edits in three files that quietly drift apart. This is the same compile-time code generation idea behind my open-source AOP framework, just pointed at navigation instead of cross-cutting concerns.

Here is what you get out of the approach, and what it costs:

  • One source of truth. The graph is derived from annotations, so a screen cannot exist without being registered.
  • Type-safe routes for free. The generated code leans on Navigation Compose's typed APIs, so arguments are checked by the compiler.
  • A real build-system cost. A navigation graph aggregates every annotated file, which has consequences for incremental builds that you need to design around rather than ignore.

Why generate a navigation graph at all

Navigation Compose has come a long way. Since Navigation 2.8.0 you can define routes as @Serializable types and register them with the generic composable<T> function, recovering typed arguments with backStackEntry.toRoute(). That removed a whole class of bugs that used to come from string routes like "profile/{userId}" and typos in argument keys.

But even with typed routes, the wiring is still manual. Every new screen means you define the route, write the composable<T> block, and remember to add it to the right NavHost. Three steps, three places, and nothing in the compiler forces you to keep them aligned. In a small app that is fine. In a modularized app with dozens of destinations, the NavHost becomes a long hand-maintained list, and "I forgot to register the screen" turns into a runtime surprise rather than a build error.

Code generation moves that surprise to compile time. The same instinct — pull a mechanical, repetitive concern out of the code you write by hand — is what pushed me toward annotation processing in the first place, the way lifecycle-aware back handling pushed me toward extension functions over OnBackPressedDispatcher.

KSP in one paragraph, and why not KAPT

KSP is a Kotlin-first API for reading your source as a resolved symbol tree and emitting new source files during compilation. It is the modern replacement for KAPT, and the official Kotlin documentation is blunt about why. KAPT has to generate Java stubs for your Kotlin code, which means parsing and resolving every type reference up front, then handing the result to javac. KSP skips the stubs: it resolves references only when a processor asks for them, which the docs put at roughly up to twice as fast for annotation processing. KSP is also Kotlin-aware (it understands things like extension functions and declaration-site variance that have no Java equivalent), its incremental model is more granular, and — importantly — it is read-only. A processor cannot modify your source; it can only generate new files alongside it.

That read-only model shapes the whole design. We are not rewriting your screens. We are reading them and producing a sibling file that ties them together. If you have read my write-up of Aspectify, my KSP/AOP framework, the pipeline below will feel familiar — the shape of a symbol processor is the same whatever annotation you hang it on.

The annotation we process

Keep the annotation small. It marks a composable as a destination and points at the @Serializable route type that identifies it.

import kotlin.reflect.KClass

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class Destination(
    val route: KClass<*>,
    val start: Boolean = false,
)

SOURCE retention is deliberate: the annotation only needs to exist during compilation, so it leaves nothing in the bytecode. A screen then looks like this, with ordinary type-safe routes:

import kotlinx.serialization.Serializable

@Serializable
object HomeRoute

@Serializable
data class ProfileRoute(val userId: String)

@Destination(route = HomeRoute::class, start = true)
@Composable
fun HomeScreen(onOpenProfile: (String) -> Unit) { /* ... */ }

@Destination(route = ProfileRoute::class)
@Composable
fun ProfileScreen(route: ProfileRoute) { /* ... */ }

Writing the SymbolProcessor

A KSP processor has two halves: a provider that the build system discovers, and the processor itself.

The provider and entry point

class NavGraphProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
        NavGraphProcessor(environment.codeGenerator, environment.logger)
}

KSP finds this class through a service file at src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider containing its fully qualified name — or, if you prefer, with an @AutoService(SymbolProcessorProvider::class) annotation that writes that file for you.

Finding and validating annotated screens

The core of process() is short. Ask the resolver for everything carrying our annotation, keep only function declarations, and split them into symbols that are fully resolved and symbols that are not yet.

class NavGraphProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver
            .getSymbolsWithAnnotation(Destination::class.qualifiedName!!)
            .filterIsInstance<KSFunctionDeclaration>()

        val (valid, deferred) = symbols.partition { it.validate() }
        if (valid.none()) return deferred.toList()

        val destinations = valid.mapNotNull { it.toDestinationModel() }
        if (destinations.isNotEmpty()) emitNavGraph(destinations)

        return deferred.toList()
    }
}

That partition { it.validate() } line is doing the multi-round work, which I will come back to. The short version: anything that is not yet resolvable gets returned so KSP can hand it back in a later round.

Reading the annotation into a plain model

Resolve each symbol into a small data class so the rest of the processor never touches the KSP API again. Note that when you read a KClass annotation argument, KSP gives you a KSType, not a reflected class.

private data class DestinationModel(
    val functionName: String,
    val packageName: String,
    val routeType: KSType,
    val isStart: Boolean,
    val containingFile: KSFile?,
)

private fun KSFunctionDeclaration.toDestinationModel(): DestinationModel? {
    val annotation = annotations.firstOrNull {
        it.shortName.asString() == "Destination"
    } ?: return null

    val routeType = annotation.arguments
        .firstOrNull { it.name?.asString() == "route" }
        ?.value as? KSType ?: return null

    val isStart = annotation.arguments
        .firstOrNull { it.name?.asString() == "start" }
        ?.value as? Boolean ?: false

    return DestinationModel(
        functionName = simpleName.asString(),
        packageName = packageName.asString(),
        routeType = routeType,
        isStart = isStart,
        containingFile = containingFile,
    )
}

Emitting the graph with KotlinPoet

You can write raw text through CodeGenerator.createNewFile(...), but for anything with structure that becomes an exercise in counting indentation. The kotlinpoet-ksp interop module is worth the dependency: you build a FileSpec with KotlinPoet, convert KSP types to KotlinPoet types with toClassName(), and hand the whole file to KSP with a single writeTo call.

private fun emitNavGraph(destinations: List<DestinationModel>) {
    val navGraphBuilder = ClassName("androidx.navigation", "NavGraphBuilder")
    val composable = MemberName("androidx.navigation.compose", "composable")

    val register = FunSpec.builder("appDestinations")
        .receiver(navGraphBuilder)

    destinations.forEach { destination ->
        val routeClass = destination.routeType.toClassName()
        val screen = MemberName(destination.packageName, destination.functionName)
        // composable<ProfileRoute> { ProfileScreen(/* args from toRoute() */) }
        register.addStatement("%M<%T> { %M() }", composable, routeClass, screen)
    }

    val startRoute = destinations.firstOrNull { it.isStart }?.routeType?.toClassName()
    val startProperty = PropertySpec.builder("StartRoute", ANY)
        .apply { if (startRoute != null) initializer("%T", startRoute) }
        .build()

    FileSpec.builder("app.generated", "GeneratedNavGraph")
        .addFunction(register.build())
        .addProperty(startProperty)
        .build()
        .writeTo(
            codeGenerator,
            aggregating = true,
            originatingKSFiles = destinations.mapNotNull { it.containingFile },
        )
}

The generated file is something you would otherwise hand-write and forget to update:

package app.generated

public fun NavGraphBuilder.appDestinations() {
    composable<HomeRoute> { HomeScreen() }
    composable<ProfileRoute> { ProfileScreen() }
}

public val StartRoute: Any = HomeRoute

Your NavHost collapses to NavHost(navController, startDestination = StartRoute) { appDestinations() }, and a new screen is never missing from it because it cannot compile without being generated in.

I kept the composable blocks deliberately bare — { HomeScreen() } — to keep the focus on the KSP pipeline. In a real generator, this is exactly where the work is: reading the route type's constructor parameters, calling backStackEntry.toRoute<ProfileRoute>(), and passing those typed arguments into the screen. That argument wiring is the part a navigation framework actually earns its keep on, and it is also the part most likely to break, so treat it as the thing you test hardest, not an afterthought.

Trade-offs and pitfalls

Code generation is not free. The honest parts are mostly about the build system.

The aggregating flag is not a detail

KSP supports incremental processing only when a processor correctly declares, for each generated file, which source files it originated from and whether it is aggregating. A per-class generator is non-aggregating: one input, one output, and touching one file reprocesses only that file. A navigation graph is the opposite. It merges many annotated files into a single output, so it is aggregating, which is why the writeTo call above passes aggregating = true and the full list of originating files. KotlinPoet collects and de-duplicates those for you when you attach them, either through this parameter or with addOriginatingKSFile(...) on the builders.

The consequence is real: with an aggregating output, changing any annotated screen invalidates the whole generated graph and reprocesses it. That is correct — the graph genuinely depends on all of them — but it means you should keep the aggregating output small and avoid folding unrelated generation into the same processor. Lie about the flag to chase build speed and you get stale generated code, which is a far worse bug than a slow build.

Multi-round processing

A symbol can reference a type that does not exist yet because another processor is about to generate it. KSP handles this by running in rounds. Your process() returns the symbols it could not finish, KSP runs again once more code exists, and KSAnnotated.validate() is how you decide what to defer. The pattern is the one above: partition on validate(), process the valid symbols, return the invalid ones. Return an empty list when everything resolves, so KSP stops looping.

One caveat worth internalizing: defer only source symbols that are genuinely missing information, never symbols from the classpath, and do not hold on to symbol objects between rounds — KSP rebuilds them, so look them up again. The exact behavior of getSymbolsWithAnnotation across later rounds has shifted between KSP versions, so verify it against the version you ship rather than against a blog post, including this one.

Testing generated code

Generated code is still code, and "it compiled on my machine" is not a test. Two layers help. First, drive the processor with a compile-testing harness such as kotlin-compile-testing, which runs your processor over an in-memory source set and lets you assert on the exact text it emits. Second, compile the output as its own source set so a bad composable<T> block fails the build instead of shipping. If you are generating code that will cooperate with coroutines, flows, and ViewModels, make the generated surface idiomatic by hand first and only then teach the processor to produce it — the same discipline I leaned on when I migrated from RxJava to Kotlin Coroutines, where the clean target shape had to exist before any mechanical transformation was safe.

Resolve only what you need

KSP's speed comes from resolving references lazily. If your processor eagerly resolves types it never uses, you give that advantage back. Read the annotation arguments and the declarations you actually emit, and let everything else stay unresolved.

Wrapping up

Generating a navigation graph with KSP is not exotic. It is a small SymbolProcessor that reads one annotation, a FileSpec built with KotlinPoet, and a disciplined aggregating flag. The payoff is that an entire category of "I forgot to register the screen" bugs becomes a compile error, and your typed routes stay honest. The two things to get right are the build-system contract — originating files and the aggregating flag — and the argument wiring you choose to generate. Start with a graph that only registers screens, prove it against the compile-testing harness, and add typed-argument generation once the skeleton is solid.

Resources

WORK TOGETHER

Let’s start with a good problem.

Looking for an Android engineer for your team, or someone to think through a technical problem with? Let’s talk.

Book a 30-minute conversation[email protected]All contact and podcast links