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

- URL: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy
- Course: [Mobile Action Pro](https://dantech.academy/en/courses/kotlin-accelerator.md)
- Module: Kotlin Multiplatform Foundations
- Duration: 15 min
- Difficulty: beginner
- Access: Free, full lesson
- Tiếng Việt: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md

## Objectives

- 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

---

## 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](https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html).

## 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](https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html).

## 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](https://kotlinlang.org/docs/multiplatform/multiplatform-run-tests.html).

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

| 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](https://developer.android.com/kotlin/multiplatform/plugin).

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

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

| Property        | Value                                    |
| --------------- | ---------------------------------------- |
| `io`            | `platformIoDispatcher` (an `expect val`) |
| `main`          | `Dispatchers.Main`                       |
| `mainImmediate` | `Dispatchers.Main.immediate`             |

`platformIoDispatcher` is an `expect val` declared in [`QzDispatchersImpl.kt`](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/kernel/src/commonMain/kotlin/com/creative/kta/kernel/dispatchers/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`](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/kernel/src/iosMain/kotlin/com/creative/kta/kernel/dispatchers/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](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-i-o.html), [kotlinx.coroutines changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md).

## 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

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

---

- Previous lesson: [Your First Safe Change](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change.md)
- Next lesson: [Platform Seams: expect/actual Or Interface + DI](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di.md)
