Annotation processing lets tools generate boilerplate code at compile time. Room generates DAOs, Hilt generates DI factories, Retrofit generates API implementations — all through annotations. KSP (Kotlin Symbol Processing) is the modern, Kotlin-first replacement for kapt.
kapt vs KSP
| Feature | kapt | KSP |
|---|---|---|
| Kotlin support | Via stub generation (slow) | Native Kotlin support |
| Build speed | ~15–25% of compilation time | ~2× faster than kapt |
| Incremental | Partial | Full |
| API | Java Element types | Kotlin KSDeclaration types |
| Multiplatform | Limited | Supported |
Migration rule: If a library supports KSP, use KSP. Room, Hilt, Moshi, and Kotlin serialization all support KSP.
// build.gradle.kts — swap kapt for ksp
plugins {
id("com.google.devtools.ksp") version "1.9.22-1.0.17" // matches Kotlin version
}
dependencies {
// ❌ Old: kapt
kapt("androidx.room:room-compiler:2.6.1")
// ✅ New: ksp
ksp("androidx.room:room-compiler:2.6.1")
}
How Annotation Processing Works
Source code + annotations
↓
Kotlin Compiler (KSP plugin runs here)
↓
Processor reads @Entity, @Dao, etc.
↓
Generates source files (RoomDao_Impl.kt, etc.)
↓
Generated sources compiled along with your code
Writing a Simple KSP Processor
Here's a toy processor that generates a toString() for classes annotated with @AutoToString:
// 1. Define the annotation
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoToString
// 2. Implement the processor
class AutoToStringProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
resolver.getSymbolsWithAnnotation(AutoToString::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>()
.forEach { generateToString(it) }
return emptyList()
}
private fun generateToString(clazz: KSClassDeclaration) {
val packageName = clazz.packageName.asString()
val className = clazz.simpleName.asString()
val properties = clazz.getAllProperties().map { it.simpleName.asString() }
val file = codeGenerator.createNewFile(
dependencies = Dependencies(false, clazz.containingFile!!),
packageName = packageName,
fileName = "${className}ToStringExt"
)
file.writer().use { writer ->
writer.write("package $packageName\n\n")
val fields = properties.joinToString(", ") { "$it=\${obj.$it}" }
writer.write("""
fun $className.toStringAuto(): String = "$className($fields)"
""".trimIndent())
}
}
}
// 3. Register the processor via SymbolProcessorProvider
class AutoToStringProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment) =
AutoToStringProcessor(environment.codeGenerator)
}
# resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
com.example.processor.AutoToStringProcessorProvider
Common KSP Use Cases in Android
| Library | Annotation | Generated code |
|---|---|---|
| Room | @Entity, @Dao | SQL query implementations |
| Hilt | @HiltViewModel, @Inject | DI factory classes |
| Moshi | @JsonClass | JSON adapter |
| Kotlin Serialization | @Serializable | Serialization code |
| Retrofit | (uses reflection; KSP not common here) | — |
Debugging KSP Processors
Generated files are in build/generated/ksp/debug/kotlin/. Read them when Room or Hilt gives cryptic compile errors — the generated code often shows the actual problem.
# Find generated files
find build/generated/ksp -name "*.kt" | head -20
# Check KSP errors
./gradlew compileDebugKotlin 2>&1 | grep "e:"
Key Takeaways
| Concept | Rule |
|---|---|
| KSP vs kapt | Prefer KSP when available — 2× faster |
| KSP plugin version | Must match your Kotlin version |
| Room with KSP | ksp("androidx.room:room-compiler:...") instead of kapt |
| Generated files | In build/generated/ksp/ — read these when debugging annotation errors |
| Custom processor | Implement SymbolProcessor + register SymbolProcessorProvider via ServiceLoader |