A new Kotlin Multiplatform project can look confusing at first: several folders with similar names, a Gradle file with an unusual kotlin { } block, and directory names such as commonMain, androidMain and iosMain. Once you understand three ideas (targets, source sets and how they depend on each other) the structure becomes predictable, and you will be able to read any KMP project.
This lesson covers The Basics of Project Structure from the Kotlin Multiplatform programme (https://developer.android.com/courses/pathways/kotlin-multiplatform), and the pages it links to: the JetBrains guide to the project structure (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-discover-project.html), the Kotlin documentation on the default hierarchy template (https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html), and Google's page on the Android Gradle library plugin for KMP (https://developer.android.com/kotlin/multiplatform/plugin).
By the end you will be able to:
- Describe the top-level layout of a KMP project (shared module, Android app, iOS app).
- Explain targets, source sets,
dependsOnand the default hierarchy template. - Read and write a
shared/build.gradle.kts, including the Android target and iOS frameworks. - Declare dependencies per source set and use a version catalog.
- Place tests in the correct source sets and run them.
- Add a custom intermediate source set when needed.
Big idea: a source set is a folder of Kotlin files plus a list of the other source sets it can see. Common code sees the least; platform code sees the most.
Honesty note: the Android part of KMP builds changed recently. Google now recommends a dedicated Gradle plugin,
com.android.kotlin.multiplatform.library, for the Android target of a shared library module; older projects usecom.android.librarywithandroidTarget(). Both appear in the wild, so this lesson shows the current plugin and explains the older form so you can recognise it. Version numbers are placeholders.
- Why KMP and
expect/actual(lesson 66). - Gradle Kotlin DSL, plugins and version catalogs (lessons 3 and 34).
- App architecture: modules and dependency rules (lesson 65).
- Kotlin interfaces and generics (lessons 11 and 13).
A typical project created from the official template (Android Studio's KMP wizard, or the JetBrains wizard at https://kmp.jetbrains.com) looks like this:
FeeTrackerKmp/
├─ gradle/libs.versions.toml shared version catalog
├─ settings.gradle.kts
├─ build.gradle.kts root plugins (apply false)
├─ androidApp/ Android application (Compose UI, Hilt, ...)
│ └─ src/main/...
├─ iosApp/ Xcode project (SwiftUI)
│ └─ iosApp.xcodeproj
└─ shared/ THE multiplatform module
├─ build.gradle.kts
└─ src/
├─ commonMain/kotlin/
├─ commonTest/kotlin/
├─ androidMain/kotlin/
└─ iosMain/kotlin/
| Part | What it is | Built with |
|---|---|---|
shared |
A Gradle module compiled for several targets, holding your shared logic | Kotlin Multiplatform plugin |
androidApp |
An ordinary Android application module that depends on :shared |
Android application plugin |
iosApp |
An Xcode project that links the framework built from :shared |
Xcode (on a Mac) |
Why is the Android app a separate module? Because the plugin for the multiplatform library has no direct replacement for the application plugin according to Google's documentation: you extract the app into its own module and keep shared as a library. This is the same modular separation you met in lesson 65.
Targets
A target is "one thing the Kotlin compiler can build for". You declare them in shared/build.gradle.kts:
| Declaration | Target |
|---|---|
android { ... } (new plugin) or androidTarget() (older) |
Android |
iosArm64() |
Physical iPhones and iPads |
iosSimulatorArm64() |
The iOS simulator on Apple-silicon Macs |
iosX64() |
The iOS simulator on Intel Macs (check whether your Kotlin version still supports it) |
jvm("desktop") |
Desktop and server JVM |
js { browser() }, wasmJs { browser() } |
Web |
You declare only what you need. A beginner project usually has Android plus the two iOS targets.
Source sets
For each target, and for groups of targets, there is a source set: a folder under src/ with a name like <name>Main (production code) and <name>Test (tests).
| Source set | Folder | Compiled for | Can use |
|---|---|---|---|
commonMain |
src/commonMain/kotlin |
All targets | Kotlin stdlib and multiplatform libraries only |
commonTest |
src/commonTest/kotlin |
All targets | kotlin.test and commonMain |
androidMain |
src/androidMain/kotlin |
Android | Android SDK, AndroidX, JVM libraries, plus commonMain |
iosMain |
src/iosMain/kotlin |
All iOS targets | Apple platform APIs (platform.*), plus commonMain |
iosArm64Main, iosSimulatorArm64Main |
per target | One iOS target | Rarely used directly |
The rule: a source set sees its own code plus everything it depends on. androidMain depends on commonMain, so Android code can call the shared code, but commonMain can never call androidMain. Information flows from specific to general, never the other way.
The default hierarchy template
You do not have to wire the source sets yourself. Since Kotlin 1.9.20, the Gradle plugin applies a default hierarchy template that creates intermediate source sets for you:
commonMain
├─ androidMain (separate branch: JVM-based target)
└─ nativeMain
└─ appleMain
└─ iosMain
├─ iosArm64Main
└─ iosSimulatorArm64Main
When you declare iosArm64() and iosSimulatorArm64(), the template gives you iosMain, appleMain and nativeMain automatically, so you can place code in iosMain and it compiles for both iOS targets, with access to the full iOS API. Source sets for targets you did not declare (such as watchosMain) are simply not created. Adding watchosArm64() later creates watchosMain, and code in appleMain, nativeMain and commonMain then also compiles for the watch.
[versions]
kotlin = "<latest stable>"
agp = "<latest stable compatible with your Android Studio>"
coroutines = "<latest stable>"
serialization = "<latest stable>"
ktor = "<latest stable>"
[libraries]
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-application = { id = "com.android.application", version.ref = "agp" }
Notice the per-platform engine pattern for Ktor: the client core is common, but the HTTP engine differs (OkHttp on Android, Darwin on iOS). That is a common KMP shape: a common API with platform-specific implementation libraries.
Google's documentation says the new Android library plugin requires AGP 8.10.0 or newer and Kotlin Gradle plugin 2.0.0 or newer. Use the latest stable pair that your Android Studio supports.
Root build.gradle.kts
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
Declaring plugins with apply false at the root puts them on the build's classpath once, so that modules can apply them by alias without version clashes.
// shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
// 1. TARGETS
android {
namespace = "com.example.fees.shared"
compileSdk = 36
minSdk = 24
// Optional, off by default in the new plugin:
withHostTest { isIncludeAndroidResources = true } // unit tests on the JVM
withDeviceTest { // instrumented tests on a device
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
val xcfName = "SharedKit"
iosArm64 { binaries.framework { baseName = xcfName } }
iosSimulatorArm64 { binaries.framework { baseName = xcfName } }
// 2. SOURCE SETS AND THEIR DEPENDENCIES
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
Read it as three questions:
- Which platforms?
android { },iosArm64()andiosSimulatorArm64(). - What frameworks do iOS targets export?
binaries.framework { baseName = ... }produces a framework namedSharedKit, which Swift imports withimport SharedKit. - Which libraries does each source set need? Common ones in
commonMain; platform engines in the platform source sets.
Details worth knowing:
- Inside
android { }the settings (namespace,compileSdk,minSdk) replace the old top-levelandroid { }block. Google's page says this DSL was previously calledandroidLibrary { }, so you may see that name in older material. Check which your AGP version expects. - Host and device tests are opt-in. When enabled, the new plugin creates the source sets
androidHostTestandandroidDeviceTest. (The page shows two spellings in different sections,withHostTest { }andwithHostTestBuilder { }.configure { }; check the API reference for your AGP version.) - Android resources, Java compilation and published consumer keep rules are off by default and must be switched on if you need them.
- The new plugin has limitations: no build types or product flavours, no data binding or view binding, no
BuildConfig, no AIDL. If you need those, keep an ordinarycom.android.librarymodule and consume it fromandroidMain.
The older style, so you can recognise it
Projects created before the dedicated plugin look like this:
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library) // com.android.library
}
kotlin {
androidTarget {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
iosArm64()
iosSimulatorArm64()
// sourceSets ...
}
android { // top-level block
namespace = "com.example.fees.shared"
compileSdk = 36
defaultConfig { minSdk = 24 }
}
Google's documentation states that the legacy KMP support in com.android.library is deprecated and expected to be removed in a future AGP major version, so new modules should use the dedicated plugin and old ones should plan to migrate. The migration page lists renamed source sets: for example src/test becomes src/androidHostTest.
Real examples for FeeTracker:
// shared/src/commonMain/kotlin/com/example/fees/data/PaymentsRepository.kt
package com.example.fees.data
import kotlinx.coroutines.flow.Flow
interface PaymentsRepository {
fun observePayments(): Flow<List<Payment>>
suspend fun refresh(): Result<Unit>
}
// shared/src/commonMain/kotlin/com/example/fees/network/ApiClient.kt
package com.example.fees.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun createHttpClient(): HttpClient = HttpClient { // engine is chosen per platform automatically
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
When you write HttpClient { } without naming an engine, Ktor picks the engine that is on the classpath of the target (OkHttp on Android, Darwin on iOS). That is why the engine dependencies live in the platform source sets.
// shared/src/androidMain/kotlin/com/example/fees/Platform.android.kt
package com.example.fees
actual fun platformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
// shared/src/iosMain/kotlin/com/example/fees/Platform.ios.kt
package com.example.fees
import platform.UIKit.UIDevice
actual fun platformName(): String = UIDevice.currentDevice.systemName()
File naming convention: Name.android.kt and Name.ios.kt beside Name.kt in commonMain. The suffix is a convention and not a requirement, but it keeps expect/actual pairs easy to find and avoids JVM class-name clashes.
Put tests in the widest source set that can compile them.
| Test location | Runs on | Use for |
|---|---|---|
commonTest |
Every target (JVM for Android host tests, iOS simulator for iOS) | Business rules, mappers, anything platform-free |
androidHostTest |
JVM on your computer | Android-specific shared code that needs Android classes with Robolectric-style setups |
androidDeviceTest |
An Android device or emulator | Instrumented tests |
iosTest |
iOS simulator | iOS-specific code |
// shared/src/commonTest/kotlin/com/example/fees/CalculateFeeSummaryTest.kt
package com.example.fees
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class CalculateFeeSummaryTest {
@Test
fun fully_paid_student_is_not_owing() {
val summary = CalculateFeeSummary(termFee = 450_000)(listOf(450_000))
assertEquals(0, summary.balance)
assertFalse(summary.isOwing)
}
}
Run them from Android Studio's gutter icon, or with Gradle. Task names depend on your plugin and Kotlin versions, so list them with ./gradlew :shared:tasks --group verification and pick the ones for the targets you have. Typically there is one task per target plus an aggregate (often allTests). iOS test tasks only run on a Mac.
For common tests that need coroutines, use kotlinx-coroutines-test (runTest), which is multiplatform:
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
Sometimes two targets share code that others do not. Example: a JVM desktop target and Android both use JVM-only libraries. Kotlin does not allow a shared source set for "Android + JVM" directly, according to the hierarchy documentation, which says shared source sets for several JVM targets, JVM plus Android, or several JS targets are not supported. In that case, put the shared JVM code in a regular JVM library module, or duplicate it, or use an interface.
For native targets you can create an intermediate source set. The documented pattern is to reapply the template and add your own set:
kotlin {
jvm()
macosArm64()
iosArm64()
iosSimulatorArm64()
applyDefaultHierarchyTemplate()
sourceSets {
val jvmAndMacos by creating {
dependsOn(commonMain.get())
}
macosArm64Main.get().dependsOn(jvmAndMacos)
jvmMain.get().dependsOn(jvmAndMacos)
}
}
Two cautions from the documentation:
- Once you call
dependsOnyourself, the default template is cancelled unless you reapply it withapplyDefaultHierarchyTemplate(); the build warns you. - You cannot change the template's own
dependsOnrelations. You may only add sets beside it, or switch the template off entirely (kotlin.mpp.applyDefaultHierarchyTemplate=falseingradle.properties) and connect everything by hand.
Most beginner projects never need this. Prefer the template.
The Android app depends on shared
// androidApp/build.gradle.kts
dependencies {
implementation(project(":shared")) // or projects.shared if type-safe accessors are enabled
}
The Android app can now use anything in commonMain and androidMain of shared. The iOS app gets the same code through the framework.
// androidApp/src/main/kotlin/.../MainActivity.kt
import com.example.fees.CalculateFeeSummary
val summary = CalculateFeeSummary()(listOf(100_000, 50_000))
Exercise 1: Which source set?
For each file decide where it belongs: (a) data class Payment; (b) a function using android.os.Build; (c) code that calls UIDevice; (d) a test of CalculateFeeSummary; (e) the Ktor Darwin engine dependency.
(a) commonMain. (b) androidMain. (c) iosMain. (d) commonTest. (e) iosMain.dependencies { } in build.gradle.kts.
Exercise 2: Fix the build error
This line is in commonMain and the build fails on the iOS targets: import java.time.LocalDate. Explain why and propose two fixes.
java.time is a JVM library and does not exist for iOS, so common code that must compile for iOS cannot import it. Fix 1: use the multiplatform kotlinx-datetime library (LocalDate there). Fix 2: define an interface or expect function in common code, and implement it with java.time in androidMain and with Foundation in iosMain.
Exercise 3: Read the hierarchy
A project declares iosArm64(), iosSimulatorArm64() and android { }. Where would you put code that uses platform.Foundation.NSDate and must work on both iOS targets, and why not in commonMain?
In iosMain (or appleMain), which the default template creates for both iOS targets. commonMain also compiles for Android, which has no platform.Foundation, so the build would fail there.
Mistake #1: Putting Android imports in commonMain. Fix: move the code to androidMain or use an interface.
Mistake #2: Declaring engine dependencies in commonMain. Fix: put platform engines in the platform source sets.
Mistake #3: Using JUnit in commonTest. Fix: use kotlin.test.
Mistake #4: Mixing legacy and new Android target setups. Fix: choose one: the dedicated plugin for new modules.
Mistake #5: Expecting build types or flavours in the new plugin. Fix: use a separate Android library module for those.
Mistake #6: Wiring dependsOn by hand without reapplying the template. Fix: call applyDefaultHierarchyTemplate() or avoid manual wiring.
Mistake #7: Forgetting binaries.framework for iOS targets. Fix: add it so Xcode gets a framework.
Mistake #8: Using a library with no iOS variant. Fix: check klibs.io before adding it.
Mistake #9: Declaring targets you never build. Fix: declare only what you need; fewer targets means faster builds.
Mistake #10: Copying a Gradle file from an old tutorial. Fix: compare it against the current official pages first.
Question 1. What does commonMain compile for?
Every target the module declares.
Question 2. Which source set sees the other: commonMain or androidMain?
androidMain sees commonMain; common code cannot see platform code.
Question 3. What does the default hierarchy template do?
It automatically creates and connects intermediate source sets (such as iosMain, appleMain, nativeMain) for the targets you declare.
Question 4. Why is the Android app a separate module from shared?
The dedicated KMP library plugin has no replacement for the application plugin, so the app lives in its own module that depends on shared.
Question 5. Where do platform-specific Ktor engines go?
In the platform source sets: OkHttp in androidMain and Darwin in iosMain.
Go deeper: official resources
- Kotlin Multiplatform programme
- Android Gradle library plugin for KMP
- Hierarchical project structure (Kotlin docs)
- Understand the project structure (JetBrains)
Project goal: Create a KMP project, read its structure, and share one rule with both platforms' test setups.
Requirements:
- Create a project from the official wizard (Android Studio's KMP template or https://kmp.jetbrains.com), with Android and iOS targets. If you have no Mac, still select iOS so you can study the structure; you will build only the Android side.
- Draw the folder tree and annotate each directory with the targets it compiles for.
- Open
shared/build.gradle.ktsand label every block (plugins, targets, framework, source sets). - Move
CalculateFeeSummaryandFeeSummaryintocommonMain, and write three tests incommonTest. - Add
kotlinx-coroutines-coreand make the use case return aFlowusing a common dependency. - Add an
expect fun platformName()withactualimplementations for Android and iOS, and show the result in the Android app. - Run the tests for the Android host target and, if you can, the iOS simulator target. Record the task names you used.
- Write a half-page note explaining which source set each file lives in and why.
Stretch goal: add a desktop JVM target and discover (and document) what the default hierarchy template adds or does not add for it.
An annotated tree (example):
shared/
├─ build.gradle.kts targets + dependencies
└─ src/
├─ commonMain/kotlin/com/example/fees/
│ ├─ FeeSummary.kt [all targets] data class
│ ├─ CalculateFeeSummary.kt [all targets] rule
│ └─ Platform.kt [all targets] expect fun platformName()
├─ commonTest/kotlin/com/example/fees/
│ └─ CalculateFeeSummaryTest.kt [all targets] kotlin.test
├─ androidMain/kotlin/com/example/fees/
│ └─ Platform.android.kt [Android] actual fun platformName()
└─ iosMain/kotlin/com/example/fees/
└─ Platform.ios.kt [iosArm64 + iosSimulatorArm64] actual fun
A flow-based use case entirely in common code:
// commonMain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ObserveFeeSummary(
private val payments: PaymentsRepository,
private val calculate: CalculateFeeSummary = CalculateFeeSummary()
) {
operator fun invoke(): Flow<FeeSummary> =
payments.observePayments().map { list -> calculate(list.map { it.amountNaira }) }
}
Showing the result in the Android app:
// androidApp
Text("Running on ${platformName()}")
Test tasks recorded (example; names depend on your plugin and Kotlin versions):
./gradlew :shared:testAndroidHostTest Android host (JVM) tests
./gradlew :shared:iosSimulatorArm64Test iOS simulator tests (Mac only)
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 67 project”.
On github.com click New repository, name it android-lesson-67-kmp-project-structure-targets-and-source-s, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 67 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: The Three Parts of a KMP Project
- Step-by-Step Build 1: The Version Catalog
- Step-by-Step Build 2: The Shared Module's Gradle File
- Step-by-Step Build 3: What Goes Where
- Step-by-Step Build 4: Tests
- Step-by-Step Build 5: Custom Source Sets and Hierarchy Rules
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check