appleMain exists only if your targets justify it
The hierarchy template inserts nodes based on declared targets. A directory for a node that does not exist compiles nowhere and warns about nothing.
TL;DR
- A file in
src/appleMain/kotlin/compiled into nothing, for five weeks, in a module whose only Apple target wasiosArm64. No error, no warning, no "source set not found" — the directory was simply never part of any compilation. - The default hierarchy template inserts an intermediate node only when two or more of its
children are declared. One Apple target means no
appleMain, so the directory is just a directory on disk. - The symptom was a missing
actual, reported at link time against a target that had never seen the file. - What reading on buys: how to ask Gradle what source sets actually exist, the one-line config
that makes an undeclared directory an error, and why
dependsOnby hand is the wrong fix.
The case this post is about
A module with one Apple target, because that is all the product shipped:
// core/keychain/build.gradle.kts
kotlin {
androidTarget()
iosArm64() // device only — the iOS team builds on device
jvm("desktop")
sourceSets {
commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) }
}
}
And a secure-storage bridge, authored where it seemed to belong:
// core/keychain/src/commonMain/kotlin/SecureStore.kt
expect class SecureStore {
fun put(key: String, value: String)
fun get(key: String): String?
}
// core/keychain/src/appleMain/kotlin/SecureStore.apple.kt ← the file in question
import platform.Security.*
actual class SecureStore {
actual fun put(key: String, value: String) { /* SecItemAdd … */ }
actual fun get(key: String): String? { /* SecItemCopyMatching … */ }
}
appleMain because the implementation is Keychain Services, which is shared by every Apple
platform — the reasoning was that it would still be right when macOS arrived.
The symptom
Five weeks later, the first iOS build after someone touched the module:
e: Expected class 'SecureStore' has no actual declaration in module <core:keychain> for 'Native'
Which reads as "you forgot to write the actual" — and the actual was right there, in the repository, in a directory named after the platform, with correct Keychain code in it.
The thing that made it survive five weeks is that nothing ever compiled it, so nothing ever complained:
# what touched src/appleMain/kotlin/SecureStore.apple.kt, 5 weeks
git commits 3
compiler errors from the file 0
compiler warnings 0
IDE errors (it resolves fine) 0
compilations it participated in 0
Three commits to a file that had never been compiled. The IDE resolved it because IntelliJ indexes the directory regardless; it was the build that had no opinion.
And the search that followed looked in the wrong place for a day and a half:
"is the KSP processor skipping it?" 4h
"is this an incremental-compilation stale?" 3h (clean build, same error)
"is the expect in the wrong source set?" 2h
asking Gradle what source sets exist 10m ← the answer

Why the obvious fix failed
The first fix was to add macosArm64() so appleMain would exist:
iosArm64()
macosArm64() // "now appleMain has two children"
Which works, and ships a macOS target the product does not have — a whole extra link, test and publish surface added to make a directory name valid. It also only defers the question: a module with one Apple target is a legitimate shape, and it should not require a fictional second target.
The second fix was to declare the node by hand:
sourceSets {
val appleMain by creating { dependsOn(commonMain.get()) }
iosArm64Main.get().dependsOn(appleMain)
}
This compiles and is the thing you will find in older answers. It also fights the hierarchy
template rather than using it — the moment a second Apple target is declared, the template inserts
its own appleMain and you have two nodes with the same intent, and the error for that is about a
duplicated dependsOn edge rather than about your manual node.
The third attempt was to rename the directory to iosArm64Main, which is correct and loses the
thing the file was written for: the code is Keychain Services, which iosMain/appleMain
describes and iosArm64Main does not.
The actual mechanism
The default hierarchy template is a tree of potential nodes, and a node is materialised by the targets you declare — not by directories on disk.
A node appears when the template's rule for it is satisfied. appleMain sits above
iosMain, macosMain, tvosMain, watchosMain; iosMain sits above iosArm64Main,
iosSimulatorArm64Main, iosX64Main. The template inserts an intermediate node when more than
one of its children is present — a node with a single child would add a level that has exactly
the same contents as the child, so the template collapses it:
declared targets nodes that exist
iosArm64 iosArm64Main ← no iosMain, no appleMain
iosArm64 + iosSimulatorArm64 iosMain → both ← still no appleMain
iosArm64 + iosSimulatorArm64 + macosArm64
appleMain → iosMain → both, + macosMain
So our module — one Apple target — had iosArm64Main and nothing above it. appleMain was never
a source set.
And a directory for a source set that does not exist is not an error. Gradle source sets are
declared, and their directory conventions follow from the declaration. There is no reverse
mapping that scans src/ and asks whether each directory corresponds to something: an unclaimed
directory is indistinguishable from src/notes/ or src/old-backup/. Nothing is looking.
Which makes the failure arrive at the wrong layer. The compiler for iosArm64 sees a
commonMain expect with no actual in its own tree, and reports exactly that. It cannot report
"there is an actual in a directory nobody compiled", because from its position that directory does
not exist.
The -Xexpect-actual-classes warning is unrelated, which cost us an hour: it fires on
matched expect/actual classes to flag that the feature is Beta. Its absence is not a signal that
nothing is matched.

The fix
Ask the build what exists, author into a node that does, and make an unclaimed directory fail the build rather than sit there.
// core/keychain/build.gradle.kts
kotlin {
androidTarget()
iosArm64()
jvm("desktop")
// The template is applied by default; naming it here is documentation rather than a change.
applyDefaultHierarchyTemplate()
sourceSets {
commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) }
}
}
/**
* An unclaimed src/<name>Main directory fails the build.
*
* src/appleMain/kotlin held a correct Keychain actual for five weeks and compiled into nothing,
* because one Apple target means the template never materialises appleMain. Three commits
* landed on a file that participated in zero compilations — no error, no warning, and the IDE
* resolves it, so every signal available to a human said it was fine.
*
* This compares the directories on disk against the source sets that actually exist. It runs
* in a doLast rather than as a compile-time check because there is no compiler hook for "a
* directory nobody asked about".
*/
val checkSourceSetDirs by tasks.registering {
// Read EAGERLY, here, at configuration time — plain values, not providers. A
// `provider { kotlin.sourceSets… }` reads identically and is not the same thing: it
// evaluates inside doLast, which captures the Kotlin extension and so the Project, and
// makes this task configuration-cache incompatible. One such task disables the cache for
// the entire build, which is a steep price for a check that runs once.
val declared: Set<String> = kotlin.sourceSets.map { it.name }.toSet()
// Only *Main / *Test names are claimed by the Kotlin plugin; src/ also legitimately holds
// things like generated/, so an unknown-shaped directory is ignored rather than guessed at.
val suspicious: List<String> = (layout.projectDirectory.dir("src").asFile
.listFiles()?.filter { it.isDirectory }?.map { it.name } ?: emptyList())
.filter { it.endsWith("Main") || it.endsWith("Test") }
.filterNot { it in declared }
doLast {
if (suspicious.isNotEmpty()) {
throw GradleException(
"src/ holds directories for source sets that do not exist: $suspicious\n" +
" declared: ${declared.sorted()}\n" +
" These compile into NOTHING. Either declare the targets that materialise the " +
"node (e.g. a second Apple target creates appleMain) or move the code into a " +
"source set that exists."
)
}
}
}
tasks.named("check") { dependsOn(checkSourceSetDirs) }
And the file moves to the node that does exist, with the reason recorded so the next person does not move it back:
// core/keychain/src/iosArm64Main/kotlin/SecureStore.ios.kt
//
// iosArm64Main, not appleMain: this module declares ONE Apple target, so the template
// materialises no iosMain and no appleMain. If a second Apple target is added, this moves up —
// checkSourceSetDirs will not complain either way, but `./gradlew printSourceSets` will show
// the node appearing.
import platform.Security.*
actual class SecureStore { /* … */ }
Plus the ten-minute diagnostic that should have been the first thing we ran:
// build-logic — available in every module
val printSourceSets by tasks.registering {
doLast {
kotlin.sourceSets.sortedBy { it.name }.forEach { ss ->
val parents = ss.dependsOn.map { it.name }.sorted()
println("${ss.name}${if (parents.isEmpty()) "" else " → $parents"}")
}
}
}
And the output it produced:
$ ./gradlew :core:keychain:printSourceSets -q
androidMain → [commonMain]
androidUnitTest → [commonTest]
commonMain
commonTest
desktopMain → [commonMain]
desktopTest → [commonTest]
iosArm64Main → [commonMain] ← straight to commonMain. No iosMain. No appleMain.
iosArm64Test → [commonTest]
$ ./gradlew :core:keychain:check
FAILURE: src/ holds directories for source sets that do not exist: [appleMain]
declared: [androidMain, androidUnitTest, commonMain, commonTest, desktopMain, desktopTest, iosArm64Main, iosArm64Test]
These compile into NOTHING. …
$ # after moving the file
$ ./gradlew :core:keychain:check :core:keychain:compileKotlinIosArm64
BUILD SUCCESSFUL in 41s
And across the whole repository, run once:
$ ./gradlew check --continue 2>&1 | grep 'source sets that do not exist' -A1
core:keychain [appleMain]
core:analytics [nativeMain] ← same class, different node: one native target
feature:scanner [iosMain] ← and one we had not noticed at all
Three modules. Two we did not know about.
How we proved it
That the node genuinely did not exist — the measurement that ended a day and a half of looking at KSP and incremental compilation:
$ ./gradlew :core:keychain:printSourceSets -q | grep -c apple
0
That the file participated in zero compilations. Established by asking the compile task what it was given, rather than inferring from the error:
$ ./gradlew :core:keychain:compileKotlinIosArm64 --info 2>&1 | grep -c 'SecureStore.apple.kt'
0
$ ./gradlew :core:keychain:compileKotlinIosArm64 --info 2>&1 | grep -c 'SecureStore.kt'
1 ← the expect, from commonMain
That the template's rule is "more than one child", tested by declaring targets and reading back the tree rather than from the documentation:
declared appleMain? iosMain?
iosArm64 no no
iosArm64, iosSimulatorArm64 no yes
iosArm64, macosArm64 yes no
iosArm64, iosSimulatorArm64, macosArm64 yes yes
Row three is the interesting one: two Apple targets on different Apple platforms materialise
appleMain without materialising iosMain, because iosMain then has one child.
That an unclaimed directory produces no signal at all. The full inventory, because the absence of every one of these is why it lasted five weeks:
signal present?
Kotlin compiler error no
Kotlin compiler warning no
Gradle configuration warning no
IDE red squiggle no (IntelliJ indexes the directory)
`./gradlew check` no (before this change)
git history no (3 commits, all clean)
That dependsOn by hand collides with the template. Tried, since it is the fix most answers
give:
$ # manual appleMain, then add iosSimulatorArm64()
e: Duplicate 'dependsOn' edge: iosArm64Main -> commonMain
(via appleMain and via iosMain)
Which names neither the manual node nor the template, and sends the next person looking at
commonMain.
What I cannot tell you is whether the check task catches a directory nested deeper —
src/appleMain/kotlin/ is checked by its top-level name, so src/ios/appleMain/ would pass. We
have no such directory and the check is deliberately shallow; a recursive scan would have to decide
what src/androidMain/res/ means, and a false positive on a build-breaking check is worse than the
gap.
And an honest consequence. The file now lives in iosArm64Main, which is less right than
appleMain as a description of Keychain Services. When macOS arrives it has to move, and nothing
reminds anyone — printSourceSets will show appleMain appearing, but only if someone runs it.

What this generalizes to
A directory is not a declaration. When a build system materialises its structure from configuration,
a directory that corresponds to nothing is invisible to every tool in the chain — the compiler never
sees it, so it cannot warn, and the IDE indexes it, so it looks fine. The general move is to ask the
build what it believes exists (printSourceSets here; the equivalent in any system is a task that
dumps the resolved model) and then to make the mismatch between belief and disk an error, because
the gap is the one thing no existing signal covers.
Related
- The source-set hierarchy is a graph, not a tree — what the nodes mean once they do exist.
- expect/actual is not an interface — why the missing actual is reported at link time rather than as a null.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".