Engineering

Source sets are a graph, and iosMain is not where you think it is

The default hierarchy creates intermediate source sets you never declared.

MobileByteSensei 9 min read
kmpgradlearchitecture

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.

A source set graph with commonMain at the root, nativeMain and the Android and JVM and wasm leaves beneath it, appleMain above iosMain and macosMain, and the three iOS leaf source sets beneath iosMain — with the note that each intermediate node exists only when the declared targets justify it, so adding macosArm64 is what creates appleMain, and a directory named appleMain before that point belongs to no compilation at all

Which explains every symptom:

An actual is 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.

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".

Follow by RSS