KSP runs once per target, and your generated code can disagree with itself
A processor sees one compilation at a time. Generating into commonMain is a race you win by accident.
TL;DR
KSP is a compiler plugin, and a Kotlin Multiplatform module has one compilation per target. So the
processor runs several times — once for androidDebug, once for iosArm64, once for wasmJs —
each seeing only the sources that compilation sees.
Two consequences. A processor that aggregates annotations produces a different registry per
target, because androidMain annotations are invisible to the iOS run. And generating into a
shared source directory means several tasks writing the same file, which is a Gradle
configuration error at best and a build-order-dependent result at worst.
Generate per target. Aggregate in commonMain by hand, or make the processor's output
target-local and have shared code consume it through an expect.
The case this post is about
A tiny annotation processor that builds a registry of screens, so navigation does not need a hand-maintained list:
// commonMain
@Screen("list") class ListScreen
@Screen("detail") class DetailScreen
// androidMain — an Android-only screen
@Screen("settings_android") class AndroidSettingsScreen
The processor collects every @Screen and emits a registry:
// generated
object ScreenRegistry {
val all = listOf("list", "detail", "settings_android")
}
Wired the way the KSP docs show for a single-platform project:
// build.gradle.kts
dependencies {
add("kspCommonMainMetadata", project(":screen-processor"))
}
It worked. Then the iOS build produced a registry missing a screen, and the desktop build produced a different one again.
The symptom
Three distinct failures.
A different registry per target. The same ScreenRegistry.all, three values:
androidDebug ["list", "detail", "settings_android"]
iosArm64 ["list", "detail"]
wasmJs ["list", "detail"]
Correct, in fact — settings_android genuinely does not exist on iOS. But shared code iterating
ScreenRegistry.all had no idea the contents varied, so a shared screen-count assertion passed on
Android and failed elsewhere.
Gradle refused the shared output directory. The attempt to make all targets see one registry:
Execution failed for task ':shared:compileKotlinIosArm64'.
> Entry generated/ksp/ScreenRegistry.kt is a duplicate but no duplicate handling strategy has been set.
Several KSP tasks writing one path. Gradle is right to stop.
And a stale registry that depended on build order. Before the duplicate error surfaced, with output wired into a shared source directory, the registry contents depended on which compilation ran last:
./gradlew build → registry had 3 entries
./gradlew clean compileKotlinIosArm64 → registry had 2 entries
./gradlew build → 3 again
A build result that changes with task order is the worst of the three, because it is invisible until it is not.
Why the obvious fix failed
"Use kspCommonMainMetadata and be done." That configuration generates for the metadata
compilation, which is what commonMain typechecks against. It does not make the output available
to the per-target compilations without extra wiring, and it only sees commonMain sources — so the
Android-only screen is invisible to it by design.
"Add the generated directory to commonMain.kotlin.srcDir." This is where the build-order
dependence came from. Several tasks write there; whichever ran last won. It also creates a task
dependency cycle risk that Gradle will eventually complain about.
"Register the processor for every target configuration." Correct, and on its own it produces the three-different-registries result — which is the real behaviour and needs to be handled rather than hidden.
"duplicatesStrategy = EXCLUDE." Silences the error and picks an arbitrary winner. This is the
fix that turns a loud build failure into the silent build-order bug, which is strictly worse.
The actual mechanism
Three facts compose into the problem.
A KMP module has one Kotlin compilation per target — plus a metadata compilation for common
code. compileKotlinIosArm64, compileDebugKotlinAndroid, compileKotlinWasmJs are separate
tasks with separate inputs.
KSP attaches to a compilation. It is given that compilation's source set closure and nothing
else. For iosArm64 that is iosArm64Main + iosMain + appleMain + nativeMain +
commonMain — and specifically not androidMain.
Each run has its own output directory. build/generated/ksp/<target>/…, which is correct and
is why a shared directory is a conflict.
So "aggregate every annotation in the module" is not a thing a single KSP run can do. Each run aggregates its own view, and the views genuinely differ.
There is a fifth compilation, and it is the one people reach for when they learn this.
kspCommonMainMetadata processes commonMain alone — one run, one view, no per-target
divergence, which sounds exactly like the fix. It has two properties that decide whether it is:
dependencies { add("kspCommonMainMetadata", project(":processor")) }
kotlin.sourceSets.commonMain {
// Without this, the generated code exists on disk and no compilation can see it.
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
// And every per-target compilation must wait for it, or the first build after a clean
// races: the Android compile starts before the generator has written anything.
tasks.withType<KotlinCompile<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") dependsOn("kspCommonMainKotlinMetadata")
}
The srcDir line is the one that bites, because omitting it fails in the least informative way
available: KSP runs, reports success, writes the file, and the build then fails on an unresolved
reference to a class you can see in build/generated.
And the limit worth being explicit about: this run sees commonMain only. Annotations in
androidMain are invisible to it — which is correct for a registry of shared types and is exactly
the wrong tool if what you are aggregating is per-platform implementations. That distinction is
what decides between this and the aggregation strategy below, and it is a property of your
annotations rather than of KSP.

A processor's output is scoped to the compilation that produced it. Per-target generation is not a limitation to route around — it is the only thing that can be correct when the inputs differ per target.
The fix
Generate per target, consume through a declared shared contract, and let the per-target difference be explicit rather than accidental.
// build.gradle.kts — one configuration per target, each with its own output
dependencies {
add("kspAndroidDebug", project(":screen-processor"))
add("kspAndroidRelease", project(":screen-processor"))
add("kspIosArm64", project(":screen-processor"))
add("kspIosSimulatorArm64", project(":screen-processor"))
add("kspWasmJs", project(":screen-processor"))
add("kspDesktop", project(":screen-processor"))
}
The processor emits a target-local object implementing a shared interface:
// commonMain — hand-written. The contract the generated code satisfies.
interface ScreenRegistry {
val all: List<String>
}
expect val screenRegistry: ScreenRegistry
// generated, per target — e.g. build/generated/ksp/iosArm64/…/GeneratedScreenRegistry.kt
internal object GeneratedScreenRegistry : ScreenRegistry {
override val all = listOf("list", "detail")
}
actual val screenRegistry: ScreenRegistry get() = GeneratedScreenRegistry
Three properties this buys:
No shared output directory, so no duplicate and no build-order dependence. Each compilation reads its own generated file.
The variation is declared. expect val screenRegistry makes it structurally obvious that each
target supplies its own, which is the honest description of what was happening anyway.
Shared code can still assert the invariants that must hold everywhere:
// commonTest — runs on every target, asserting the PORTABLE subset
private val CORE_SCREENS = setOf("list", "detail")
@Test
fun everyTargetRegistersTheCoreScreens() {
assertTrue(screenRegistry.all.containsAll(CORE_SCREENS))
}
@Test
fun noDuplicateRoutes() {
assertEquals(screenRegistry.all.size, screenRegistry.all.distinct().size)
}
That first test is what the broken shared-count assertion should have been: assert the screens that must exist, not a total that legitimately varies.
For the genuinely cross-target aggregate, if you need one, build it in common code from the per-target registries rather than from a processor:
// commonMain — the union, for a docs page or an analytics schema
val knownRoutes: Set<String> = CORE_SCREENS + PLATFORM_EXTRA_ROUTES
With PLATFORM_EXTRA_ROUTES an expect val — one more declared difference instead of a hidden
one.
And one Gradle detail worth knowing, because it is the part the docs do not emphasise: the
metadata compilation needs its own handling if commonMain itself must see generated code at
typecheck time. The clean way is for commonMain to see only hand-written declarations (the
interface and the expect), which is what the design above does — it sidesteps the metadata
problem entirely rather than configuring around it.
How we proved it
That each target gets its own correct registry:
$ ./gradlew clean build
$ find build/generated/ksp -name 'GeneratedScreenRegistry.kt' | while read f; do
echo "$f: $(grep -o 'listOf(.*)' "$f")"
done
…/ksp/androidDebug/…: listOf("list", "detail", "settings_android")
…/ksp/iosArm64/…: listOf("list", "detail")
…/ksp/wasmJs/…: listOf("list", "detail")
…/ksp/desktop/…: listOf("list", "detail")
Reading the generated files directly is the check, not inferring from behaviour.
That the result no longer depends on build order — the regression test for the worst symptom:
$ ./gradlew clean compileKotlinIosArm64 && grep -c settings_android <iosArm64 generated>
0
$ ./gradlew build && grep -c settings_android <iosArm64 generated>
0
$ ./gradlew clean build && grep -c settings_android <iosArm64 generated>
0
Three different orders, same answer. Previously this sequence produced 2, 3, 3.
That the portable invariants hold on every target:
./gradlew allTests
jvmTest(desktop) everyTargetRegistersTheCoreScreens PASSED
androidUnitTest everyTargetRegistersTheCoreScreens PASSED
iosSimulatorArm64Test everyTargetRegistersTheCoreScreens PASSED
wasmJsTest everyTargetRegistersTheCoreScreens PASSED
And that a missing registration fails — because a registry test that cannot fail is decoration:
// remove @Screen from DetailScreen
iosSimulatorArm64Test everyTargetRegistersTheCoreScreens FAILED
expected to contain "detail"
One limit: this assumes each target's registry is independently valid, which is true for screens
and not for everything. A processor generating something that must be globally consistent — a
single ID-to-type mapping where the numbers must match across targets — cannot be solved this way;
that needs the aggregate to be hand-written in commonMain, with the per-target processors
verifying it rather than producing it.
What this generalizes to
A code generator's scope is its compilation unit's scope. Annotation processors, macro expanders, schema generators — each sees one unit. "Collect everything in the project" is usually not expressible, and designs that assume it break the first time the project has more than one unit.
Several writers to one output is a bug even when it appears to work. The build-order-dependent registry is the shape to fear: correct most of the time, wrong after a particular sequence, and with nothing in the output indicating which you got. A duplicate-file error is a gift.
duplicatesStrategy and friends convert loud failures into quiet ones. Any flag whose effect
is "stop telling me about the conflict" deserves suspicion. The conflict was information.
Assert the invariant subset, not the total. The shared test that failed was asserting a count that legitimately varies. When a value differs per environment, find the part that must not and assert that — a total is rarely the invariant you actually care about.
Related
- Source sets are a graph, and iosMain is not where you think it is — the source set closure each compilation sees
- kotlinx.serialization is compile-time, which is why your polymorphism fails at runtime — another compiler plugin with scope limits
- commonTest runs on every target, which is the point and the cost — where the invariant tests run
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".