Dan Tech Academy
After this lesson you will
  • 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
Duration15 min
DifficultyBeginner

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.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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.

TermAnswers the questionExample in the repo
TargetWhich machine this module compiles forAndroid, iosArm64, iosSimulatorArm64
Source setWhich targets this code compiles forcommonMain, 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:

text
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:

bash
./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. nativeMain and appleMain exist but are empty. Code in iosMain runs on both real iPhones and the Simulator, so you almost never need a separate iosArm64Main.
  • The docs state it clearly: when a project only has iOS targets, nativeMain and appleMain still 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 setRun by taskMachine needed
commonTesttestAndroidHostTest (JVM)Any
commonTestiosSimulatorArm64TestMac
androidHostTesttestAndroidHostTestAny
iosTestiosSimulatorArm64TestMac

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:

text
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:

CharacteristicConsequence in the repo
A single variant, no build types or product flavorsKMP libraries have no debug/release BuildConfig. Build config lives in :app and reaches shared code through AppBuildConfig
No native build (NDK) supportA module that needs the NDK must be a plain Android module. The plugin's KDoc says so explicitly
Tests off by defaultThe plugin has to turn on withHostTest {} before testAndroidHostTest exists
Android resources off by defaultThe 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 codeWhy it doesn't compileWhat the repo uses instead
java.io.File, java.*iOS has no JDKString paths through the DataStoreFileResolver interface
Context, ActivityAndroid-onlyAn interface in commonMain, with the implementation provided through Koin
BuildConfigGenerated per Android build type, and KMP libraries have no build typesAppBuildConfig, passed in by :app or iosAppBuildConfig
TimberAn Android-only libraryKermit (co.touchlab.kermit). Timber only remains in androidMain and :app
java.timeA JDK APIkotlinx-datetime, for example in LocalGameHistoryRepository
Class.forName, reflectionKotlin/Native has no JVM-style reflectionThe 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:

bash
./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:

PropertyValue
ioplatformIoDispatcher (an expect val)
mainDispatchers.Main
mainImmediateDispatchers.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:

kotlin
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:

  1. settings.gradle.kts: the module list.
  2. build-logic/ or buildSrc/: the convention plugins that declare targets, SDKs and the toolchain. Read them once and you understand every module.
  3. Each module's build.gradle.kts: the plugins actually applied and the dependencies per source set (commonMain.dependencies, androidMain.dependencies...).
  4. Each module's src/ folder: which source sets hold real code.
  5. Search for the keyword expect to see where the project depends on a platform.
  6. Check whether gradle.properties has a kotlin.mpp.applyDefaultHierarchyTemplate=false line, and whether any build file has dependsOn(.

Troubleshooting Table

SymptomUsual causeFix
Unresolved reference 'java' or 'android' in commonMainA JVM or Android API used in shared codeSwitch to a multiplatform library or split it out behind an interface
Unresolved reference 'IO' in commonMainMissing import kotlinx.coroutines.IOAdd the import, or use QzDispatchers like the repo
Tests green on Linux but the iOS app breaksOnly testAndroidHostTest ran; the iOS tests didn'tRun iosSimulatorArm64Test on a Mac
Compose Resources fail at runtime on Android even though the build is greenA new, hand-configured module is missing the enableAndroidResources flagUse kta.kmp.library instead of configuring the plugin yourself
Gradle warns that the default hierarchy template wasn't appliedThe 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 Metadata and point out which intermediate source sets appear,
  • explain why code in iosMain runs on both iPhones and the Simulator,
  • say what testAndroidHostTest proves and what it doesn't,
  • name three things that can't go in commonMain and how the repo replaces them,
  • explain why Dispatchers.IO works in this repo's commonMain but wouldn't compile if a web target were added.
Sign in