kapt is described as legacy. As a principal engineer, how would you decide whether and how to migrate a large multi-module project off kapt?
answer
- Migrate where a KSP variant exists; keep kapt otherwise
- kapt + KSP coexist; move processors one at a time
- ksp(...) replaces kapt(...); ksp { arg() } replaces kapt arguments
- KSP plugin version paired to Kotlin version
- Measure with build scans / --profile; migrate per module
basics
~10 sCheck which of your annotation processors have a faster KSP version. Migrate those module-by-module, keep kapt only for the few processors that have no KSP option, and measure build-time improvement before and after.
solid answer
~50 skapt is legacy mainly because of its stub-generation overhead; KSP reads Kotlin symbols directly and is usually ~2x faster. Migration strategy: (1) **inventory processors** and check each for a KSP variant — Room, Moshi-codegen, Glide, and Hilt/Dagger have KSP support in current versions, while a few niche processors do not; (2) you can run **kapt and KSP side by side** in the same module during transition, applying both the `kotlin("kapt")` and `com.google.devtools.ksp` plugins and moving processors one at a time from `kapt(...)` to `ksp(...)`; (3) for each migrated processor, swap the dependency configuration, drop any now-unneeded `correctErrorTypes`, and verify generated output and tests; (4) **measure** with Gradle build scans / `--profile` to confirm wins and watch incrementality. Keep kapt only where no KSP processor exists. Risks: subtle differences in generated code, error-type handling, and processor-arg names; do it per module behind CI.
code
kotlin · 14 linesplugins {
kotlin("jvm") version "2.1.0"
kotlin("kapt") version "2.1.0"
id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}
dependencies {
ksp("androidx.room:room-compiler:2.6.1") // migrated
kapt("com.example:legacy-processor:1.0") // no KSP variant yet
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}go deeper
Knows KSP is the modern, faster replacement for kapt.
Can swap a single processor from kapt(...) to ksp(...) and translate its arguments.
Plans a side-by-side, per-module migration, handles version pairing, and removes correctErrorTypes/kapt once a module is clean.
Drives the whole decision: inventories processors, weighs KSP availability vs. effort, measures build-time impact with scans, and de-risks via CI while accepting kapt where no KSP exists.
## Why migrate at all kapt's cost is structural: it runs a **stub-generation** pass (a near-full Kotlin front-end compile that emits Java stubs) before any processing, hurting build time and incrementality. **KSP (Kotlin Symbol Processing)** inspects Kotlin **symbols** directly via its own API — no Java stubs — and is typically about **2x faster** for the same processor. So migration is primarily a **build-performance and developer-experience** decision. ## Step 1 — inventory and feasibility List every processor on `kapt(...)` across modules and check for a **KSP variant**: - Room (`room-compiler`) — has KSP. - Moshi-codegen, Glide — have KSP. - Dagger/Hilt — KSP support exists in current versions. - Some older/niche processors — **kapt-only**; these must stay on kapt. No KSP variant = no migration for that processor (yet); that's a legitimate reason kapt remains in a codebase. ## Step 2 — incremental, side-by-side migration kapt and KSP can coexist in one module: ```kotlin plugins { kotlin("jvm") version "2.1.0" kotlin("kapt") version "2.1.0" // still here for unmigrated processors id("com.google.devtools.ksp") version "2.1.0-1.0.29" } dependencies { // migrated to KSP ksp("androidx.room:room-compiler:2.6.1") // not yet migrated — stays on kapt kapt("com.example:legacy-processor:1.0") } ``` Move processors from `kapt(...)` to `ksp(...)` **one at a time**, keeping the build green between steps. Note the **KSP plugin/version is paired to the Kotlin version** (e.g. `2.1.0-1.0.29`), so bump them together. ## Step 3 — clean up kapt-specific config - Translate processor arguments: kapt's `kapt { arguments { arg(k, v) } }` becomes `ksp { arg(k, v) }`. - Drop `correctErrorTypes` once the relevant processors are off kapt (KSP doesn't use stubs, so the error-type problem doesn't apply). - When a module's last kapt processor is migrated, remove the `kotlin("kapt")` plugin entirely to delete the stub pass. ## Step 4 — measure and de-risk - Use **Gradle build scans** / `./gradlew --profile` / `--scan` to confirm faster, more-incremental builds. - Watch for **generated-code differences**: KSP processors can emit slightly different code or have different arg names; gate behind CI and review generated output and tests. - Migrate **per module** so a regression is isolated. ## Decision summary - KSP variant exists + meaningful build-time pain → migrate. - No KSP variant → keep kapt for that processor; isolate it so other modules stay kapt-free. - Always migrate behind tests/CI and validate generated output. ## Keywords `com.google.devtools.ksp`, `ksp(...)` vs `kapt(...)`, symbols vs stubs, KSP/Kotlin version pairing, build scans/`--profile`, `correctErrorTypes` removal.
- Can kapt and KSP run in the same module simultaneously?Yes. You apply both plugins and keep some processors on kapt(...) while others move to ksp(...), which enables an incremental, one-processor-at-a-time migration.
- What is a valid reason to keep kapt in a 2026 codebase?A processor with no KSP variant. Since KSP reads symbols not Java stubs, only processors that ship a KSP implementation can be migrated; kapt remains the fallback for the rest.
Like swapping a slow translator (kapt) for someone who reads the original language (KSP) — but you keep the translator around for the few documents the new reader can't yet handle.
saying these in an interview costs you the question
- Assuming every kapt processor has a KSP equivalent
- Recommending a big-bang migration across all modules at once with no measurement
- Forgetting that the KSP plugin version is tied to the Kotlin version
- Claiming KSP and kapt cannot coexist in one module
- Ignoring possible differences in generated code/processor arguments