Skip to Content
Course content

217: Building a Custom Annotation Processor

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

A few years ago, I was working on a massive fintech project where we had nearly a hundred different API request objects. For every single one, the team was manually writing a "Validator" class that checked if the fields were present and formatted correctly. It was mind-numbing work. I remember watching a junior dev spend an entire Tuesday just copy-pasting validation logic from UserRequest to AccountRequest, changing only the variable names. It felt like a waste of human intelligence. I decided to stop the madness by building a custom annotation processor that could look for a @Validatable annotation and automatically generate the boilerplate validation logic during the compilation phase.

In Kotlin, we typically do this using KSP (Kotlin Symbol Processing). While you might have heard of KAPT, KSP is the modern standard because it's faster and understands Kotlin's native symbols much better than the Java-centric APT. The core idea is that your processor runs during compilation. It scans the source code for specific annotations, analyzes the symbols (classes, functions, properties) associated with those annotations, and then spits out new Kotlin files. It's important to remember that KSP cannot modify existing code—it can only generate new files. If you want to add a method to an existing class, you'll need to generate an extension function in a separate file.

Defining the Processor and Provider

To get started, you don't just write a class; you need a SymbolProcessorProvider. Think of this as the entry point that the Kotlin compiler uses to instantiate your processor. The provider's job is simple: it tells the compiler how to create your SymbolProcessor instance and passes along the SymbolProcessorEnvironment, which gives you access to the logger and the code generator.

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

The actual SymbolProcessor is where the heavy lifting happens. You'll override the process function, which is called by the compiler. This is where you ask KSP to find all symbols annotated with your target annotation—let's use @Route as our example—and then decide what to do with them.

Navigating Symbols and Filtering

Once you've gathered your symbols, you'll find they are wrapped in a KSAnnotated interface. You'll usually want to cast these to KSClassDeclaration if you're targeting classes. This is the part where I always tell developers to be careful: always validate that the symbol is actually what you expect. Just because a class has your annotation doesn't mean it's a class you can actually work with; it might be an interface or a sealed class that doesn't fit your logic.

override fun process(resolver: Resolver): List<KSAnnotated> {
    val symbols = resolver.getSymbolsWithAnnotation("com.example.Route")
    val unableToProcess = symbols.filterNot { it is KSClassDeclaration }
    
    symbols.filterIsInstance<KSClassDeclaration>().forEach { classDecl ->
        val className = classDecl.simpleName.asString()
        val packageName = classDecl.packageName.asString()
        generateRouteRegistry(packageName, className)
    }
    
    return unableToProcess.toList()
}

The Resolver is your window into the codebase. It allows you to look up types, find properties, and navigate the hierarchy of the classes being processed. If your processor needs to "wait" for other generated files to exist before it can run, you return those symbols in the list returned by process, telling KSP to run another round of processing.

Writing Files with CodeGenerator

The final piece of the puzzle is the CodeGenerator. This is the API that actually writes the .kt files to the disk. I highly recommend using a library like KotlinPoet for this. Writing raw strings to a file is a recipe for syntax errors and missing curly braces. KotlinPoet allows you to build the file structure using a type-safe DSL.

When you call codeGenerator.createNewFile, you provide the dependencies (the original symbols) so that the build system knows when to re-run the processor. If you change a class annotated with @Route, the build system knows that the generated RouteRegistry is now stale and needs to be recreated. This incremental processing is what keeps your build times from exploding as your project grows.




📋 Practical Task

Build a @DependencyRegistry Generator

Your goal is to create a KSP processor that automates the creation of a central registry for "Service" classes. Instead of manually adding every service to a list in your main application file, you will build a tool that does it for you.

  • The Annotation: Create an annotation called @Injectable.
  • The Logic: The processor should scan for all classes annotated with @Injectable.
  • The Output: Generate a Kotlin file named ServiceRegistry.kt in the same package as the annotated classes. This file should contain a singleton object ServiceRegistry with a list called allServices containing the simple names of all classes marked as @Injectable.
  • Requirement: Ensure that only classes (not interfaces or functions) are processed. If a non-class is annotated, use the KSPLogger to print a warning.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.