- Tell targets apart from source sets and read the source set tree the default hierarchy template creates
- Know where tests in commonTest, androidHostTest and iosTest run and what each test command proves
- Recognize APIs that can't be used in commonMain and how the repo replaces them, including how to use the right dispatcher on iOS
Anatomy Of A KMP Project
Look inside a Kotlin Multiplatform module: targets, source sets, the default hierarchy template, the Android plugin for KMP libraries and what can't go in commonMain.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
This lesson goes inside a KMP module: what a target is, what a source set is, which APIs the code in each source set may call, and why a line that works fine on Android won't compile in commonMain.
After this lesson, when you open any KMP project, you can read what it compiles to, where each platform's code lives, and predict which error will appear when you put code in the wrong place.
Part 1: Targets And Source Sets
These two terms often get mixed up, but they answer two different questions.
| Term | Answers the question | Example in the repo |
|---|---|---|
| Target | Which machine this module compiles for | Android, iosArm64, iosSimulatorArm64 |
| Source set | Which targets this code compiles for | commonMain, androidMain, iosMain |
Targets are declared once for every module in the KmpLibraryConventionPlugin convention plugin (in the build-logic/ folder): one Android target through the androidLibrary block, and two iOS targets, iosArm64() for real iPhones and iosSimulatorArm64() for the Simulator on Apple Silicon Macs. The repo doesn't declare iosX64, so the Simulator on Intel Macs is outside the build.
A source set is a folder of code plus its dependencies. KMP's most important rule lives here: the compiler only lets a source set use APIs that exist on every target that source set compiles to. commonMain compiles for both Android and iOS, so it can't see android.*, and it can't see java.* either.
Official docs: Discover your project.
Part 2: The Default Hierarchy Template
You won't find a line in any build file that creates iosMain. The Kotlin Gradle plugin builds it from the default hierarchy template: based on the targets you declare, the plugin creates the matching intermediate source sets and connects them.
With the repo's three targets, the production source set tree is:
commonMain
├── androidMain
└── nativeMain
└── appleMain
└── iosMain
├── iosArm64Main
└── iosSimulatorArm64Main
The test side has a matching tree (commonTest, iosTest...). The repo's Android test source set is called androidHostTest, covered in Part 3.
You can check this tree yourself without a Mac:
./gradlew :kernel:tasks --all | grep Metadata
The output includes compileCommonMainKotlinMetadata, compileNativeMainKotlinMetadata, compileAppleMainKotlinMetadata and compileIosMainKotlinMetadata. Each task is an intermediate source set the plugin created.
Three things to remember when you read other projects:
- This repo only uses
iosMain.nativeMainandappleMainexist but are empty. Code iniosMainruns on both real iPhones and the Simulator, so you almost never need a separateiosArm64Main. - The docs state it clearly: when a project only has iOS targets,
nativeMainandappleMainstill see the full iOS API. Don't assume from the names that they are restricted. - If an older project wires source sets by hand with
dependsOn(...), the template is turned off and Gradle prints a warning. If you see that warning in a project you inherit, read the manual configuration carefully before adding a new target.
Docs: Source set hierarchy.
Part 3: Where Tests Run
Tests in commonTest don't run "nowhere in particular". They run on each target.
| Source set | Run by task | Machine needed |
|---|---|---|
commonTest | testAndroidHostTest (JVM) | Any |
commonTest | iosSimulatorArm64Test | Mac |
androidHostTest | testAndroidHostTest | Any |
iosTest | iosSimulatorArm64Test | Mac |
The testAndroidHostTest task comes from the withHostTest {} line in KmpLibraryConventionPlugin. A comment in the plugin states the purpose: run commonTest on the Android host. androidHostTest is for tests that need JVM-only libraries, such as MockK or JUnit.
The practical consequence: a green test on a Windows or Linux machine only proves the logic is correct on the JVM. iOS code in iosMain only gets tested when someone runs iosSimulatorArm64Test on a Mac. On the JVM side, the test command for KMP modules is testAndroidHostTest. The iOS commands in this lesson were not run on a Mac when it was written.
Docs: Test your multiplatform app.
Part 4: The Android Plugin For KMP Libraries
Open KmpLibraryConventionPlugin. It applies two plugins:
org.jetbrains.kotlin.multiplatform
com.android.kotlin.multiplatform.library
The second one is the Android plugin made specifically for KMP libraries. Older KMP projects use com.android.library with androidTarget(). Google states that this approach relies on APIs deprecated since AGP 9. The repo uses AGP 9.0.0, so it follows the new plugin.
The new plugin differs from com.android.library in a few ways that affect how you split code:
| Characteristic | Consequence in the repo |
|---|---|
| A single variant, no build types or product flavors | KMP libraries have no debug/release BuildConfig. Build config lives in :app and reaches shared code through AppBuildConfig |
| No native build (NDK) support | A module that needs the NDK must be a plain Android module. The plugin's KDoc says so explicitly |
| Tests off by default | The plugin has to turn on withHostTest {} before testAndroidHostTest exists |
| Android resources off by default | The plugin turns on android.experimental.kmp.enableAndroidResources. The comment explains: without this flag, Compose Resources still compile but aren't packaged into the APK, and every resource read fails at runtime |
A note for upgrades: Google's docs say that since AGP 8.12 there is an android {} block that replaces androidLibrary {}, and androidLibrary {} is deprecated as of AGP 9.1. The repo is on AGP 9.0.0 with androidLibrary {}. When you upgrade AGP, this is the first thing to change.
Docs: Set up the Android Gradle Library Plugin for KMP.
Part 5: What Can't Live In commonMain
When you move Android code into commonMain, most compile errors fall into a few familiar groups. The repo has been through all of them, so each group has a real solution you can look at.
| Familiar Android code | Why it doesn't compile | What the repo uses instead |
|---|---|---|
java.io.File, java.* | iOS has no JDK | String paths through the DataStoreFileResolver interface |
Context, Activity | Android-only | An interface in commonMain, with the implementation provided through Koin |
BuildConfig | Generated per Android build type, and KMP libraries have no build types | AppBuildConfig, passed in by :app or iosAppBuildConfig |
| Timber | An Android-only library | Kermit (co.touchlab.kermit). Timber only remains in androidMain and :app |
java.time | A JDK API | kotlinx-datetime, for example in LocalGameHistoryRepository |
Class.forName, reflection | Kotlin/Native has no JVM-style reflection | The explicit list in AppFeatures.kt |
The error shows up in the commonMain metadata compile step, which runs on any machine. Try putting import java.io.File into a file in kernel/src/commonMain, then run:
./gradlew :kernel:compileCommonMainKotlinMetadata
The build fails with Unresolved reference 'java'. This is the fastest way to check shared code before you push to CI. Remember to delete the test line after you've seen the error.
Part 6: Dispatchers On iOS
Open :kernel, package dispatchers. The QzDispatchers interface has three properties: io, main and mainImmediate. QzDispatchersImpl implements them:
| Property | Value |
|---|---|
io | platformIoDispatcher (an expect val) |
main | Dispatchers.Main |
mainImmediate | Dispatchers.Main.immediate |
platformIoDispatcher is an expect val declared in QzDispatchersImpl.kt: Dispatchers.IO on Android and Dispatchers.Default on iOS. The KDoc explains why this indirection is still needed: each platform has exactly one place that decides what "I/O" means, and without it, disk reads and writes would run on the calling thread.
Many older docs say Kotlin/Native has no Dispatchers.IO. That's no longer true. The kotlinx.coroutines changelog records Dispatchers.IO being added to Kotlin/Native in version 1.7.0, and the API reference declares it for the JVM and Native target groups. The repo uses kotlinx.coroutines 1.10.2, and the KDoc of QzDispatchersImpl.ios.kt says exactly this: Dispatchers.IO exists on Kotlin/Native, but the iOS version still picks Dispatchers.Default, because its worker pool is where DataStore reads and writes run. In the commonMain of a project with only Android and iOS, you can use Dispatchers.IO with a separate import:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
val io = Dispatchers.IO
This snippet was test-compiled in :kernel with compileCommonMainKotlinMetadata. On the JVM, IO is a member of Dispatchers, so no extra import is needed. In shared code it's an extension, so leaving out import kotlinx.coroutines.IO gives an unresolved error. If the project later adds a JS or Wasm target, Dispatchers.IO doesn't exist there and the shared code stops compiling.
Keeping Dispatchers.Default on iOS is a deliberate choice in the repo, and it works correctly. The part worth learning is the design: features don't call Dispatchers.* directly but receive QzDispatchers through Koin, so tests can swap in a kotlinx-coroutines-test dispatcher, and changing the iOS dispatcher touches one file.
Dispatchers.Main on iOS runs on the operating system's main queue, exactly where UIKit requires UI updates to happen.
Docs: Dispatchers.IO API, kotlinx.coroutines changelog.
Part 7: Reading An Unfamiliar KMP Project
When you pick up a new KMP project, go in this order:
settings.gradle.kts: the module list.build-logic/orbuildSrc/: the convention plugins that declare targets, SDKs and the toolchain. Read them once and you understand every module.- Each module's
build.gradle.kts: the plugins actually applied and the dependencies per source set (commonMain.dependencies,androidMain.dependencies...). - Each module's
src/folder: which source sets hold real code. - Search for the keyword
expectto see where the project depends on a platform. - Check whether
gradle.propertieshas akotlin.mpp.applyDefaultHierarchyTemplate=falseline, and whether any build file hasdependsOn(.
Troubleshooting Table
| Symptom | Usual cause | Fix |
|---|---|---|
Unresolved reference 'java' or 'android' in commonMain | A JVM or Android API used in shared code | Switch to a multiplatform library or split it out behind an interface |
Unresolved reference 'IO' in commonMain | Missing import kotlinx.coroutines.IO | Add the import, or use QzDispatchers like the repo |
| Tests green on Linux but the iOS app breaks | Only testAndroidHostTest ran; the iOS tests didn't | Run iosSimulatorArm64Test on a Mac |
| Compose Resources fail at runtime on Android even though the build is green | A new, hand-configured module is missing the enableAndroidResources flag | Use kta.kmp.library instead of configuring the plugin yourself |
| Gradle warns that the default hierarchy template wasn't applied | The build file has hand-written dependsOn(...) | Remove the manual configuration or call applyDefaultHierarchyTemplate() again |
Checkpoint
You are done with this lesson when you can:
- run
./gradlew :kernel:tasks --all | grep Metadataand point out which intermediate source sets appear, - explain why code in
iosMainruns on both iPhones and the Simulator, - say what
testAndroidHostTestproves and what it doesn't, - name three things that can't go in
commonMainand how the repo replaces them, - explain why
Dispatchers.IOworks in this repo'scommonMainbut wouldn't compile if a web target were added.