Kotlin KSP Internals: How Your Annotations Become Generated Code

skydovesJaewoong Eum (skydoves)||11 min read

Kotlin KSP Internals: How Your Annotations Become Generated Code

If you use Jetpack Room, every @Dao interface turns into a full database implementation. If you use Hilt, every @Inject constructor gets wired into a dependency graph. If you use Moshi, every @JsonClass generates a JSON adapter. You add one annotation, hit Build, and new source files appear in your build/generated/ksp directory. The engine behind all of these is KSP, Kotlin Symbol Processing.

In this article, you'll start from a practical processor that you'd write yourself, then trace inward through the KSP pipeline: how Gradle discovers your processor, how the Resolver lets you query the entire codebase as a symbol tree, how the multi round processing loop handles dependencies between generated files, and how KSP tracks which files need reprocessing on incremental builds.

The fundamental problem: Why KAPT was slow

Before KSP, the only way to do annotation processing in Kotlin was KAPT (Kotlin Annotation Processing Tool). KAPT works by generating Java stub files from your Kotlin source code, then feeding those stubs to the standard javac annotation processing pipeline. This means the Kotlin compiler has to generate a complete set of Java declarations for every Kotlin class, interface, and function in your project, even if only a handful of them carry annotations.

For a project with hundreds of Kotlin files, this stub generation can add 20 to 30 seconds to each build. The stubs are thrown away after processing, so the work is purely overhead.

KSP takes a different approach. Instead of generating Java stubs and running through javac, KSP reads the Kotlin compiler's own symbol tree directly. Your processor receives KSClassDeclaration, KSFunctionDeclaration, and KSPropertyDeclaration objects that represent the actual Kotlin program structure, including Kotlin specific features like nullable types, extension functions, sealed classes, and default parameter values that get lost in Java stubs.

The result is that KSP processors run roughly twice as fast as equivalent KAPT processors, and they see a more accurate representation of the source code.

To make this concrete: if you have a Kotlin data class with a suspend function, a default parameter value, and a nullable generic type, KAPT's Java stub loses the suspend modifier (it becomes a Continuation parameter), the default value (it becomes an overload), and the nullability (it becomes a platform type annotation). A KSP processor sees all of these as first class Kotlin constructs. This matters for processors like Room, which need to know whether a DAO function is suspending to generate the correct coroutine wrapper.

What a KSP processor looks like

A KSP processor is two classes: a SymbolProcessorProvider that creates the processor, and a SymbolProcessor that does the work. The provider is discovered at build time via Java's ServiceLoader mechanism.

Here is a minimal processor that generates a factory class for every class annotated with @AutoFactory:

class AutoFactoryProcessorProvider : SymbolProcessorProvider {
    override fun create(
        environment: SymbolProcessorEnvironment
    ): SymbolProcessor {
        return AutoFactoryProcessor(
            environment.codeGenerator,
            environment.logger
        )
    }
}

The SymbolProcessorEnvironment gives your processor everything it needs: a CodeGenerator for writing files, a KSPLogger for diagnostics, a map of options from the build script, and version information.

The processor itself implements a single method, process, which receives a Resolver and returns a list of deferred symbols:

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

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation(
            "com.example.AutoFactory"
        )
        val unprocessable = symbols.filter { !it.validate() }

        symbols.filter { it.validate() }
            .filterIsInstance<KSClassDeclaration>()
            .forEach { generateFactory(it) }

        return unprocessable.toList()
    }
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor