Skip to Content
Course content

108: Annotation Processing with KSP Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was working with a dev who was convinced their laptop was dying. Every time they made a tiny change to a data class in our core module, the build took nearly two minutes. They were staring at a progress bar, complaining that the "Kotlin compiler is just slow." When I looked at the build logs, I saw the culprit: KAPT. The project had dozens of annotation processors, and KAPT was spending the vast majority of that time generating Java stubs just so the Java-based annotation processors could understand the Kotlin code. It was a massive overhead for something that felt like it should be native.

That's why we're revisiting KSP. If you've used KAPT, you know it's basically a wrapper. KSP (Kotlin Symbol Processing) is the native alternative. It doesn't generate stubs; it plugs directly into the Kotlin compiler. The result isn't just a marginal improvement—it's often a 2x or 3x speedup in build times for heavily annotated projects. But the way you write a KSP processor is fundamentally different from the javax.annotation.processing API you might be used to.

The Symbol Processor Lifecycle

In KSP, you aren't dealing with "Elements" in the Java sense. You're dealing with KSNodes. The heart of your processor is the SymbolProcessor interface. When the compiler hits your processor, it hands you a Resolver. Think of the resolver as your search engine; it's how you ask the compiler, "Hey, find me every single class that has the @Route annotation."

class RouteProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation("com.myapp.Route")
        val unableToProcess = symbols.filterNot { it.validate() }.toList()
        
        symbols.filter { it.validate() }.forEach { symbol ->
            if (symbol is KSClassDeclaration) {
                generateRouteRegistry(symbol)
            }
        }
        
        return unableToProcess
    }
}

One detail that often trips people up is the return value of process(). You return a list of symbols that you couldn't process in this round. This is crucial for incremental processing. If your processor generates a new class that *also* has an annotation your processor needs to handle, you return that symbol so KSP knows to run another round. If you just return an empty list, you're telling KSP you're done.

Navigating the AST with Visitors

Once you have a KSClassDeclaration, you don't want to be manually looping through properties and checking types with a bunch of if/else blocks. It gets messy fast. Instead, we use the Visitor pattern. By implementing KSVisitor, you can define exactly what happens when the processor encounters a property, a function, or a class.

I usually recommend creating a specific visitor for each generation task. For example, if you're generating a RouteRegistry, your visitor should only care about the class name and its constructor parameters. By isolating the "discovery" logic in a visitor, your main SymbolProcessor stays clean, and you avoid the "pyramid of doom" where you're indented ten levels deep just to find a property's name.

The Nuances of Code Generation

Generating the actual file is where the CodeGenerator comes in. You'll notice it requires a Dependencies object. This is the secret sauce for incremental compilation. If you tell KSP that the file you're generating depends on MyRouteClass.kt, KSP is smart enough to only re-run your processor if that specific file changes. If you pass Aggregating dependencies, KSP knows that any change in the project might affect the output. Use Isolating whenever possible; your build times will thank you.

A quick tip: don't try to write raw strings for your generated code. It's a nightmare to maintain and prone to syntax errors. Use KotlinPoet. It handles imports, indentation, and type naming for you, so you can focus on the logic of the processor rather than whether you forgot a closing curly brace in a string template.




📋 Practical Task

Build a Static Schema Generator for SQLite Tables

Your goal is to create a KSP processor that scans for a custom @Table annotation on classes and a @Column annotation on properties. Instead of the app discovering the schema at runtime via reflection, your processor should generate a TableSchemaRegistry object that contains a map of table names to their corresponding column names.

  • Create a @Table(name: String) and @Column(name: String) annotation.
  • Implement a SymbolProcessor that finds all classes annotated with @Table.
  • Use a KSVisitor to extract the table name from the class annotation and the column names from the properties annotated with @Column.
  • Use CodeGenerator (and ideally KotlinPoet) to produce a file named TableSchemaRegistry.kt.
  • The generated file should look something like:
    object TableSchemaRegistry { 
                val tables = mapOf("users" to listOf("id", "username", "email")) 
            }
  • Ensure you use Dependencies.isolating to keep the build incremental.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.