Skip to main content
kotlin in depth advanced patterns for java engineers

KSP, Annotation Processing, and Compiler Plugins

10 min read Chapter 15 of 21

KSP, Annotation Processing, and Compiler Plugins

The KAPT Problem

Java’s annotation processing (APT) has a clean model: the compiler discovers annotated elements, hands them to your processor, and your processor generates new source files. Tools like Dagger, MapStruct, and AutoValue are built on it.

When Kotlin arrived, it needed to participate in this ecosystem. The solution was KAPT (Kotlin Annotation Processing Tool), and it works through a clever but expensive hack:

  1. The Kotlin compiler parses your .kt files
  2. KAPT generates Java stubs (.java files) from the Kotlin declarations
  3. The Java annotation processor runs against those stubs
  4. Generated Java sources are compiled alongside your Kotlin code
┌──────────┐    ┌────────────┐    ┌──────────────┐    ┌───────────┐
│ .kt files│───>│ Kotlin     │───>│ Java stubs   │───>│ Java APT  │
│          │    │ compiler   │    │ (.java)      │    │ processor │
└──────────┘    └────────────┘    └──────────────┘    └───────────┘


                                                    ┌───────────┐
                                                    │ Generated │
                                                    │ sources   │
                                                    └───────────┘

This roundtrip has three significant costs:

Performance: Generating Java stubs from Kotlin source takes time. On large projects, KAPT can account for 25-40% of total build time.

Information loss: Kotlin-specific features — extension functions, inline classes, sealed hierarchies, type aliases, default parameter values — have no direct Java representation. The stubs approximate them, but annotation processors see a degraded view of your code.

Error quality: When something goes wrong, error messages reference the generated stubs, not your Kotlin source. You debug a file you never wrote.

KSP: Direct Access to Kotlin Symbols

KSP (Kotlin Symbol Processing) bypasses the stub generation entirely. It hooks into the Kotlin compiler’s frontend and gives processors direct access to Kotlin’s symbol model:

┌──────────┐    ┌────────────┐    ┌──────────────┐
│ .kt files│───>│ Kotlin     │───>│ KSP          │
│          │    │ compiler   │    │ processor    │
└──────────┘    │ (frontend) │    │              │
                └────────────┘    └──────┬───────┘


                                   ┌───────────┐
                                   │ Generated │
                                   │ .kt files │
                                   └───────────┘

No stubs, no Java intermediary. KSP understands extension functions, sealed classes, nullable types, default values — everything KAPT loses in translation. Google reports KSP is roughly 2x faster than KAPT, and on projects with heavy annotation processing, the difference is dramatic.

Writing a KSP Processor: Auto-Generating Builders

Let’s build a practical KSP processor that generates Builder classes for data classes annotated with @AutoBuilder.

Step 1: Define the Annotation

// annotations/src/main/kotlin/AutoBuilder.kt
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoBuilder

Step 2: Project Setup

// processor/build.gradle.kts
plugins {
    kotlin("jvm")
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:2.1.10-1.0.29")
}
// app/build.gradle.kts
plugins {
    kotlin("jvm")
    id("com.google.devtools.ksp") version "2.1.10-1.0.29"
}

dependencies {
    implementation(project(":annotations"))
    ksp(project(":processor"))
}

Step 3: Implement the Processor

The KSP API has two key interfaces: SymbolProcessorProvider (factory) and SymbolProcessor (logic).

// processor/src/main/kotlin/AutoBuilderProcessorProvider.kt
class AutoBuilderProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return AutoBuilderProcessor(environment.codeGenerator, environment.logger)
    }
}

Register the provider via the service loader:

// processor/src/main/resources/META-INF/services/
//   com.google.devtools.ksp.processing.SymbolProcessorProvider
com.example.AutoBuilderProcessorProvider

Now the processor itself:

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

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation("com.example.AutoBuilder")
        val unprocessable = mutableListOf<KSAnnotated>()

        symbols.forEach { symbol ->
            if (symbol !is KSClassDeclaration) {
                logger.error("@AutoBuilder can only be applied to classes", symbol)
                return@forEach
            }

            if (!symbol.validate()) {
                unprocessable.add(symbol)
                return@forEach
            }

            generateBuilder(symbol)
        }

        return unprocessable // deferred for next round
    }

    private fun generateBuilder(classDecl: KSClassDeclaration) {
        val packageName = classDecl.packageName.asString()
        val className = classDecl.simpleName.asString()
        val builderName = "${className}Builder"

        val primaryConstructor = classDecl.primaryConstructor
            ?: run {
                logger.error("@AutoBuilder requires a primary constructor", classDecl)
                return
            }

        val parameters = primaryConstructor.parameters

        val file = codeGenerator.createNewFile(
            dependencies = Dependencies(aggregating = false, classDecl.containingFile!!),
            packageName = packageName,
            fileName = builderName
        )

        file.bufferedWriter().use { writer ->
            writer.appendLine("package $packageName")
            writer.appendLine()
            writer.appendLine("class $builderName {")

            // Generate private fields
            parameters.forEach { param ->
                val name = param.name!!.asString()
                val type = param.type.resolve().declaration.qualifiedName!!.asString()
                writer.appendLine("    private var $name: $type? = null")
            }
            writer.appendLine()

            // Generate setter methods
            parameters.forEach { param ->
                val name = param.name!!.asString()
                val type = param.type.resolve().declaration.qualifiedName!!.asString()
                writer.appendLine("    fun $name(value: $type) = apply { this.$name = value }")
            }
            writer.appendLine()

            // Generate build method
            writer.appendLine("    fun build(): $className {")
            writer.appendLine("        return $className(")
            parameters.forEachIndexed { index, param ->
                val name = param.name!!.asString()
                val separator = if (index < parameters.size - 1) "," else ""
                writer.appendLine("            $name = $name ?: error(\"$name is required\")$separator")
            }
            writer.appendLine("        )")
            writer.appendLine("    }")
            writer.appendLine("}")
        }
    }
}

Step 4: Use It

@AutoBuilder
data class User(val name: String, val email: String, val age: Int)

// After KSP runs, this code compiles:
val user = UserBuilder()
    .name("Alice")
    .email("[email protected]")
    .age(30)
    .build()

The generated UserBuilder class lives in build/generated/ksp/ and is fully type-safe.

Key KSP Concepts

Deferred symbols: When process() returns a list of KSAnnotated, those symbols are deferred to the next processing round. This handles cases where Symbol A depends on Symbol B, which hasn’t been generated yet. KSP gives you multiple rounds to resolve dependencies — return an empty list when you’re done.

Incremental processing: KSP tracks which input files affect which output files through the Dependencies parameter in createNewFile(). If you specify aggregating = false with the specific source file, KSP knows to re-run the processor only when that file changes. With aggregating = true, any change triggers reprocessing.

Type resolution: KSType is KSP’s representation of resolved types. You can inspect nullability (isMarkedNullable), type arguments (arguments), and declaration (declaration). Unlike KAPT’s TypeMirror, KSType preserves Kotlin-specific type information.

val returnType = function.returnType?.resolve()
val isNullable = returnType?.isMarkedNullable ?: false
val typeArgs = returnType?.arguments ?: emptyList()

Compare this with Java APT: A Java annotation processor uses RoundEnvironment, TypeMirror, and javax.annotation.processing.Processor. The mental model is similar, but the KSP API is more Kotlin-idiomatic and exposes richer type information. If you’ve written Java annotation processors, KSP should feel familiar — with fewer sharp edges.

Kotlin Compiler Plugins

While KSP generates new source files, compiler plugins can transform existing code during compilation. They operate inside the compiler itself and can modify the AST (Abstract Syntax Tree), add synthetic declarations, and alter codegen behavior.

You don’t write compiler plugins often — most developers use the ones provided by JetBrains and the ecosystem. Understanding what they do and when they activate helps you reason about behavior that seems impossible from the source code alone.

all-open

// build.gradle.kts
plugins {
    kotlin("plugin.allopen") version "2.1.10"
}

allOpen {
    annotation("javax.persistence.Entity")
    annotation("org.springframework.stereotype.Component")
}

Kotlin classes are final by default. Spring’s CGLIB proxies and Hibernate’s lazy-loading proxies require non-final classes. The all-open plugin makes annotated classes and their members open at compile time — you don’t write open in your source, but the bytecode has it.

Without this plugin, every JPA entity and Spring bean would need open class, open fun scattered everywhere. The plugin removes that friction while preserving Kotlin’s default-final semantics for non-framework code.

no-arg

noArg {
    annotation("javax.persistence.Entity")
}

JPA requires a no-argument constructor for entity instantiation. Kotlin data classes with required constructor parameters don’t have one. The no-arg plugin synthesizes a zero-argument constructor in bytecode — invisible in your source, but present for JPA’s reflection-based instantiation.

The synthetic constructor doesn’t run property initializers, so it exists solely for framework compatibility. You can’t call it from Kotlin code.

kotlin-serialization

@Serializable
data class ApiResponse(
    val status: Int,
    val message: String,
    val data: List<Item>
)

The serialization plugin generates a KSerializer<ApiResponse> implementation at compile time. No reflection, no runtime class scanning. The generated serializer knows every property’s name, type, and position, and it creates efficient serialization code directly.

Compare this with Jackson, which uses reflection to discover properties at runtime, or Gson, which uses Unsafe.allocateInstance() to create objects. The compiler plugin approach is faster (no reflection), safer (schema mismatches are compile errors), and works on all Kotlin platforms (JVM, JS, Native).

compose

The Jetpack Compose compiler plugin is the most transformative of the set. It rewrites @Composable functions to thread a Composer parameter through every call, implements positional memoization to skip recomposition of unchanged subtrees, and generates group markers for the runtime to track UI structure.

@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

This function’s bytecode signature takes a Composer and an Int changed-flags parameter that don’t appear in your source. The plugin is why @Composable functions can only be called from other @Composable functions — the compiler enforces context propagation.

The K2 Compiler and FIR Plugin API

Kotlin’s K2 compiler (stable since Kotlin 2.0) replaces the old frontend with a new FIR (Frontend Intermediate Representation) architecture. For plugin authors, this means a new API surface.

The FIR plugin API offers:

  • FIR extensions: Modify the intermediate representation before backend codegen. You can add synthetic declarations, alter visibility, inject supertypes.
  • Better performance: FIR’s lazy resolution means plugins don’t trigger full compilation of unrelated code.
  • Richer analysis: Plugins can participate in type checking and resolution, not just code generation.

The API is stabilizing but still evolving. JetBrains hasn’t committed to a stable compiler plugin API yet — plugins must be updated for each Kotlin version. This is a deliberate trade-off: keeping the API internal lets the compiler team iterate faster.

For most use cases, KSP is the recommended entry point. Compiler plugins are necessary only when you need to modify existing declarations (like all-open making classes non-final) rather than generating new code.

When to Use What

CriterionKSPCompiler PluginRuntime Reflection
Generate new classes/filesBest choiceCan do, but overkillN/A
Modify existing classesCannotRequiredLimited (proxies)
Build speed impactLowLowNone (runtime cost)
API stabilityStableUnstable (version-coupled)Stable
Kotlin multiplatformFull supportDepends on pluginJVM only
Complexity to implementModerateHighLow
Runtime performanceZero overheadZero overheadReflection overhead
DebuggingInspect generated sourceRead compiler IR dumpsStandard debugging

Choose KSP when you need to generate boilerplate code based on annotations — builders, serializers, DI modules, repository implementations, mapping functions. KSP is the direct successor to KAPT for these use cases.

Choose a compiler plugin when you need to alter the behavior of existing declarations — change visibility, inject constructors, transform function signatures, or thread implicit parameters. The barrier to entry is higher: you’re working inside the compiler, and your plugin must be updated for each Kotlin release.

Choose runtime reflection when the decision must happen at runtime (plugin systems, dynamic configuration, testing frameworks) and the performance cost is acceptable. kotlin-reflect gives you access to KClass, KFunction, KProperty with full Kotlin type information. Use it sparingly — on hot paths, the overhead of reflective access is measurable.

For the vast majority of code generation scenarios you’ll encounter, KSP is the right tool. It’s stable, performant, well-documented, and doesn’t couple your build to a specific Kotlin compiler version. Start there, and reach for compiler plugins only when KSP’s source-generation model isn’t sufficient.