Source sets are a graph, and iosMain is not where you think it is
The default hierarchy creates intermediate source sets you never declared.
TL;DR
iosMain is not a target. It is an intermediate source set that the default hierarchy template
creates above iosArm64Main, iosX64Main and iosSimulatorArm64Main — and it only exists if you
actually declared iOS targets.
Source sets form a directed graph, not a two-level common/platform split. A given compilation sees
every source set above it in that graph. So the same actual declaration can satisfy three
targets from one file, or satisfy none, depending entirely on which node you put it in — and
putting it in the wrong node produces an "expected declaration has no actual" error that names a
target rather than the mistake.
Read the graph before adding a target. ./gradlew :shared:dependencies does not show it; one small
task does.
The case this post is about
A shared module with Android, three iOS targets, desktop and wasm:
// build.gradle.kts
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm("desktop")
wasmJs { browser() }
}
One platform capability — secure storage — needs an actual per platform:
// commonMain
expect class SecureStore() {
fun put(key: String, value: String)
fun get(key: String): String?
}
Android goes in androidMain. Desktop in desktopMain. And the Apple one goes in iosMain,
written once for both iOS targets, because that is the whole point of an intermediate source set.
It compiled. Then a teammate added macosArm64() for a developer tool, and everything about my
mental model turned out to be wrong.
The symptom
Adding one target broke a file that had nothing to do with it:
e: Expected class 'SecureStore' has no actual declaration in module <shared>
for Compilation 'main' of target 'macosArm64'
Fair enough — macOS needs an actual. So the fix looked obvious: move the Keychain implementation up to a shared Apple source set, since macOS has a Keychain too.
Except there was no appleMain directory to move it to, and creating one did nothing:
mkdir -p src/appleMain/kotlin && git mv src/iosMain/kotlin/SecureStore.kt src/appleMain/kotlin/
e: Expected class 'SecureStore' has no actual declaration in module <shared>
for Compilation 'main' of target 'iosArm64'
e: Expected class 'SecureStore' has no actual declaration in module <shared>
for Compilation 'main' of target 'iosSimulatorArm64'
e: Expected class 'SecureStore' has no actual declaration in module <shared>
for Compilation 'main' of target 'macosArm64'
Now three targets failed instead of one. A directory named appleMain was simply not part of
any compilation — the sources in it were invisible to everything.
Then a second, subtler version appeared in a different module. An actual placed in
iosArm64Main compiled green on CI for weeks, because CI built only the device target. It failed
the first time someone ran the simulator.
Why the obvious fix failed
"Create appleMain and it will be picked up." Directories are not source sets. The Kotlin
Gradle plugin registers source sets from the target declarations plus a hierarchy template;
an arbitrary directory name matches nothing and is silently ignored. Silently is the problem —
no warning, no "unused source directory", just sources that do not exist as far as the build is
concerned.
"Copy the file into each platform source set." Works, and now the Keychain implementation exists three times. The next change to it will be made in two of the three.
"dependsOn from each target to a new source set." This is actually the right mechanism, and
my first attempt wired it backwards — I declared appleMain.dependsOn(iosArm64Main) instead of
the other way round. The direction encodes "refines", and getting it inverted produces errors that
look identical to not having wired it at all.
"Just put it back in iosMain and add a separate macOS copy." What I shipped that afternoon,
and it is the two-copies problem with extra steps — plus iosMain and macosMain then drift,
which is how a fix for one platform silently fails to reach the other.
The actual mechanism
A Kotlin Multiplatform build has a source set graph, built from two things.
Your target declarations. Each target gets a leaf source set — iosArm64Main,
androidMain, wasmJsMain.
The default hierarchy template. Applied automatically in recent Kotlin versions, it inserts
intermediate nodes only where the targets justify them. Declare two iOS targets and you get
iosMain. Declare an iOS target and a macOS target and you get appleMain above both. Declare
only iosArm64 and iosMain does not exist — which is why the same project layout works for
one team and not another.
The failure mode when a node does not exist is the one that costs the afternoon: the directory is
simply not compiled. Create src/iosMain/kotlin/ in a module declaring one iOS target and
Gradle does not error, does not warn, and does not build it. Your actual declarations sit there
unreferenced, and the error you get is that the expect has no implementation for iosArm64 —
pointing at the declaration rather than at the perfectly good actual thirty lines away in a
directory nothing is reading.
$ ls src/iosMain/kotlin/ # exists, populated, ignored
Platform.ios.kt
e: expect declaration `Platform` has no actual declaration in module <app> for Native (iosArm64)
A typo'd source set name does exactly the same thing, for the same reason, which makes the two
indistinguishable from the message. The check that separates them is asking the build what it
actually resolved rather than what you intended — ./gradlew :module:dependencies or a quick
kotlin.sourceSets.forEach { println(it.name) }, either of which lists the nodes that exist and
ends the guessing immediately.
A compilation sees every source set above it. Compiling iosArm64Main includes
iosArm64Main + iosMain + appleMain + nativeMain + commonMain — whichever of those the
template actually created. That is why one actual in iosMain satisfies both iOS targets: both
compilations include it.

Which explains every symptom:
An
actualis visible to exactly the targets below its source set in the graph. Place it too low and other targets cannot see it. Place it in a node the template did not create and nobody can see it — including the target you wrote it for.
My appleMain directory failed because adding macosArm64() creates appleMain as a node, but
the move happened in the same commit and I had assumed the directory alone was sufficient. The
mkdir was never the mechanism.
The fix
Let the template do the work, and verify the graph rather than assuming it.
// build.gradle.kts
kotlin {
applyDefaultHierarchyTemplate() // explicit, so a reader knows where nodes come from
androidTarget()
iosArm64()
iosSimulatorArm64()
macosArm64()
jvm("desktop")
wasmJs { browser() }
}
With macosArm64() declared, appleMain is a real node above iosMain and macosMain. The
Keychain implementation lives there, once:
src/
├── commonMain/kotlin/SecureStore.kt expect
├── appleMain/kotlin/SecureStore.kt actual — Keychain, serves iOS + macOS
├── androidMain/kotlin/SecureStore.kt actual — EncryptedSharedPreferences
├── desktopMain/kotlin/SecureStore.kt actual — OS keyring
└── wasmJsMain/kotlin/SecureStore.kt actual — refuses; no secure store in a browser
Two additions make this durable rather than a thing you rediscover.
A task that prints the graph, because no built-in command shows it:
// build.gradle.kts
tasks.register("sourceSetGraph") {
doLast {
kotlin.sourceSets.sortedBy { it.name }.forEach { ss ->
val parents = ss.dependsOn.map { it.name }.sorted()
println("${ss.name}${if (parents.isEmpty()) "" else " -> $parents"}")
}
}
}
$ ./gradlew -q :shared:sourceSetGraph
androidMain -> [commonMain]
appleMain -> [nativeMain]
desktopMain -> [commonMain]
iosArm64Main -> [iosMain]
iosMain -> [appleMain]
iosSimulatorArm64Main -> [iosMain]
macosArm64Main -> [macosMain]
macosMain -> [appleMain]
nativeMain -> [commonMain]
wasmJsMain -> [commonMain]
Thirty seconds of reading replaces an afternoon of guessing. Run it the moment you add a target.
And build every target in CI, which is what would have caught the iosArm64Main placement
weeks earlier:
./gradlew :shared:compileKotlinIosArm64 \
:shared:compileKotlinIosSimulatorArm64 \
:shared:compileKotlinMacosArm64 \
:shared:compileKotlinWasmJs \
:shared:compileDebugKotlinAndroid \
:shared:compileKotlinDesktop
A missing actual is a compile error by construction — the mechanism is already strict. It only
protects you for targets you actually compile.
If you genuinely need a grouping the template does not create, wire it explicitly, in the direction that means "refines":
// a node shared by desktop and android but NOT ios — the template has no such group
val jvmSharedMain by creating { dependsOn(commonMain.get()) }
androidMain.get().dependsOn(jvmSharedMain)
named("desktopMain").get().dependsOn(jvmSharedMain)
How we proved it
Two claims: the single actual serves both Apple targets, and a wrongly-placed actual is now caught.
One file, three Apple targets:
$ ./gradlew :shared:compileKotlinIosArm64 :shared:compileKotlinIosSimulatorArm64 \
:shared:compileKotlinMacosArm64
BUILD SUCCESSFUL
$ find src -name 'SecureStore.kt' | grep -c -E 'apple|ios|macos'
1
One file satisfying three compilations is the property an intermediate source set exists to provide, and counting the files is the way to show it rather than assert it.
A deliberately misplaced actual now fails:
$ git mv src/appleMain/kotlin/SecureStore.kt src/iosArm64Main/kotlin/
$ ./gradlew :shared:compileKotlinIosSimulatorArm64
e: Expected class 'SecureStore' has no actual declaration in module <shared>
for Compilation 'main' of target 'iosSimulatorArm64'
That is the regression test for the weeks-green-on-CI bug, and it only fires because CI now compiles the simulator target. The strictness was always there; the coverage was not.
And the invisible-directory case, which is the one with no error at all:
$ mkdir -p src/watchosMain/kotlin && cp src/appleMain/kotlin/SecureStore.kt src/watchosMain/kotlin/
$ ./gradlew -q :shared:sourceSetGraph | grep watchos || echo "watchosMain: NOT A SOURCE SET"
watchosMain: NOT A SOURCE SET
No build error, no warning, and a file full of code that is compiled by nothing. This is why the graph task matters more than it looks: it is the only thing that renders a source set's non-existence.
One limit: the hierarchy template's exact node set depends on the Kotlin version and on your declared targets, so the graph printed above is this project's, not a general reference. The durable practice is running the task, not memorising the output.
What this generalizes to
A convention-based system is invisible until you print it. The source set graph, a DI object graph, a module dependency tree, a CSS cascade — all derived, all consequential, none of them in your source. A ten-line task that renders the derived structure pays for itself the first time.
A directory name is not a registration. Filesystem layout matching a convention is how a lot of tooling works, and the failure mode when it does not match is silence. Whenever a framework discovers things by path, ask how you would know if it had not discovered yours.
Edge direction encodes meaning, and inverting it fails identically to missing it. dependsOn
meaning "refines" is the kind of detail that takes an hour the first time. When wiring any graph by
hand, state what the direction means in a comment next to it.
Strictness only covers what you run. The missing-actual error is one of the better compiler diagnostics in the ecosystem, and it protected nothing for the target CI never built. A strong check on a narrow surface is a weak check.
Related
- One Gradle build, five outputs, and the cache that only helps three — compiling every target affordably
- expect/actual is not an interface, and the difference shows up at link time — what the missing-actual error is telling you
- commonTest runs on every target, which is the point and the cost — the same graph, for tests
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".