In the previous tutorial, we created a KMP project and ran it on Android and iOS. Now let’s understand exactly how that project is organized — because once you understand the structure, everything else in KMP makes sense.
This is the tutorial that separates developers who struggle with KMP from those who build confidently. Take your time.
The Core Idea: Source Sets
A KMP project is organized into source sets. Each source set is a collection of Kotlin files that compile to specific platforms.
Source Sets in a typical Android + iOS project:
commonMain → compiles to ALL platforms (Android + iOS)
commonTest → tests for commonMain
androidMain → compiles to Android ONLY
androidTest → tests for Android-specific code
iosMain → compiles to iOS ONLY (all iOS targets)
iosTest → tests for iOS-specific code
Think of source sets as folders with rules:
- commonMain = “this code works everywhere”
- androidMain = “this code only works on Android”
- iosMain = “this code only works on iOS”
The File System Layout
When you open a KMP project, here is what you see on disk:
shared/
└── src/
├── commonMain/ ← Shared code for ALL platforms
│ └── kotlin/
│ ├── models/
│ │ └── User.kt
│ ├── repository/
│ │ └── UserRepository.kt
│ └── Platform.kt ← expect declaration
│
├── commonTest/ ← Shared tests
│ └── kotlin/
│ └── UserTest.kt
│
├── androidMain/ ← Android-specific code
│ └── kotlin/
│ └── Platform.android.kt ← actual for Android
│
└── iosMain/ ← iOS-specific code
└── kotlin/
└── Platform.ios.kt ← actual for iOS
The naming convention matters. Kotlin uses the folder name to determine which platform the code belongs to. commonMain = common. androidMain = Android. iosMain = iOS.
What Goes Where: The Decision Guide
This is the most important section. When you write a new file, you need to decide which source set it belongs to.
Put in commonMain When:
The code uses only Kotlin standard library and KMP-compatible libraries. No platform-specific APIs.
// commonMain — this works everywhere
data class User(
val id: String,
val name: String,
val email: String
)
fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".")
}
class UserRepository {
fun getUsers(): List<User> = listOf(
User("1", "Alex", "alex@example.com"),
User("2", "Sam", "sam@example.com"),
)
}
This code uses only Kotlin — data class, String, List. No Android or iOS APIs. It belongs in commonMain.
Put in androidMain When:
The code uses Android APIs — anything from android.*, androidx.*, or Java libraries.
// androidMain — this only compiles for Android
import android.content.Context
import android.content.SharedPreferences
class AndroidPreferences(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
fun saveToken(token: String) {
prefs.edit().putString("auth_token", token).apply()
}
fun getToken(): String? {
return prefs.getString("auth_token", null)
}
}
This uses android.content.Context and SharedPreferences — Android-only APIs. It must be in androidMain.
Put in iosMain When:
The code uses Apple APIs — anything from platform.* (Foundation, UIKit, etc.).
// iosMain — this only compiles for iOS
import platform.Foundation.NSUserDefaults
class IosPreferences {
private val defaults = NSUserDefaults.standardUserDefaults
fun saveToken(token: String) {
defaults.setObject(token, forKey = "auth_token")
}
fun getToken(): String? {
return defaults.stringForKey("auth_token")
}
}
This uses NSUserDefaults — an Apple API. It must be in iosMain.
Visibility Rules: Who Can See What
This is where most beginners get confused. Here are the rules:
commonMain can see: commonMain ONLY
androidMain can see: commonMain + androidMain
iosMain can see: commonMain + iosMain
In plain English:
- Platform-specific code CAN use shared code (androidMain can import from commonMain)
- Shared code CANNOT use platform-specific code (commonMain cannot import from androidMain)
- Android code CANNOT see iOS code (androidMain cannot import from iosMain)
- iOS code CANNOT see Android code (iosMain cannot import from androidMain)
Why This Matters
// commonMain/kotlin/MySharedClass.kt
import android.util.Log // ❌ COMPILE ERROR!
// commonMain cannot see android APIs
import platform.Foundation.NSLog // ❌ COMPILE ERROR!
// commonMain cannot see iOS APIs
import kotlin.math.sqrt // ✅ OK
// Kotlin standard library is available everywhere
If you try to use android.util.Log in commonMain, the project won’t compile for iOS. The compiler stops you because that code doesn’t exist on iOS.
The expect/actual Pattern — Bridging the Gap
So commonMain can’t use platform APIs directly. But sometimes you NEED platform-specific behavior in shared code. That is what expect/actual solves.
The Pattern
// Step 1: In commonMain — declare WHAT you need
expect fun getCurrentTimeMillis(): Long
// Step 2: In androidMain — provide the Android answer
actual fun getCurrentTimeMillis(): Long {
return System.currentTimeMillis()
}
// Step 3: In iosMain — provide the iOS answer
actual fun getCurrentTimeMillis(): Long {
return (platform.Foundation.NSDate().timeIntervalSince1970 * 1000).toLong()
}
expect = “I need this function, but I don’t know how each platform implements it.”
actual = “Here is how this platform does it.”
Rules for expect/actual
Every
expectmust have anactualon every platform. If you forget the iOS implementation, the project won’t compile.The signatures must match exactly. Same name, same parameters, same return type.
You can use expect/actual for:
- Functions
- Properties
- Classes
- Objects
- Interfaces (less common)
Real-World Examples
Logging
// commonMain
expect fun logDebug(tag: String, message: String)
// androidMain
actual fun logDebug(tag: String, message: String) {
android.util.Log.d(tag, message)
}
// iosMain
actual fun logDebug(tag: String, message: String) {
platform.Foundation.NSLog("[$tag] $message")
}
Now you can call logDebug("App", "User logged in") from commonMain and it works on both platforms.
UUID Generation
// commonMain
expect fun generateUuid(): String
// androidMain
actual fun generateUuid(): String {
return java.util.UUID.randomUUID().toString()
}
// iosMain
actual fun generateUuid(): String {
return platform.Foundation.NSUUID().UUIDString()
}
Platform Info
// commonMain
expect class PlatformInfo() {
val name: String
val version: String
val isDebug: Boolean
}
// androidMain
actual class PlatformInfo {
actual val name: String = "Android"
actual val version: String = "${android.os.Build.VERSION.SDK_INT}"
actual val isDebug: Boolean = android.os.Build.TYPE == "debug"
}
// iosMain
actual class PlatformInfo {
actual val name: String = "iOS"
actual val version: String = platform.UIKit.UIDevice.currentDevice.systemVersion
actual val isDebug: Boolean = Platform.isDebugBinary
}
When NOT to Use expect/actual
For most things, you don’t need expect/actual at all. The KMP library ecosystem provides multiplatform solutions:
| Need | Don’t Use expect/actual | Use This Instead |
|---|---|---|
| HTTP requests | Platform HTTP clients | Ktor Client |
| Database | Platform databases | SQLDelight or Room |
| JSON parsing | Platform JSON libs | Kotlin Serialization |
| Key-value storage | SharedPreferences/NSUserDefaults | DataStore |
| DI | Platform DI frameworks | Koin |
| Date/time | Platform date APIs | kotlinx-datetime |
| Coroutines | Platform threading | kotlinx-coroutines |
Use expect/actual only for things that don’t have a KMP library. In most projects, you’ll have fewer than 5 expect/actual declarations.
How Compilation Works
When you build your KMP project, the Kotlin compiler does different things for each platform:
Android Build
commonMain + androidMain → JVM bytecode → Android APK
The compiler takes all Kotlin files from commonMain and androidMain, compiles them together into JVM bytecode, and packages them into your Android app.
iOS Build
commonMain + iosMain → Native ARM binary → Shared.framework
The Kotlin/Native compiler takes files from commonMain and iosMain, compiles them into a native ARM binary, and packages it as an Objective-C framework. Xcode then includes this framework in your iOS app.
What This Means for You
- Android sees your shared code as regular Kotlin/JVM classes — no bridging needed
- iOS sees your shared code as Objective-C classes — Swift imports them through the framework
- Tests in commonTest run on ALL platforms — one test, verified everywhere
Intermediate Source Sets
For projects with multiple Apple targets, KMP creates intermediate source sets automatically:
commonMain
├── androidMain
└── appleMain ← Auto-created intermediate set
├── iosMain
│ ├── iosArm64Main (real devices)
│ ├── iosX64Main (Intel simulators)
│ └── iosSimulatorArm64Main (Apple Silicon simulators)
├── macosMain
└── tvosMain
appleMain is code that runs on ALL Apple platforms (iOS, macOS, tvOS, watchOS). You can use Apple Foundation APIs here:
// appleMain — available on ALL Apple platforms
import platform.Foundation.NSUUID
import platform.Foundation.NSDate
fun appleUuid(): String = NSUUID().UUIDString()
fun appleTimestamp(): Double = NSDate().timeIntervalSince1970
You don’t need to create appleMain manually — Kotlin creates it when you have multiple Apple targets. Most beginners can ignore intermediate source sets and just use commonMain + iosMain.
Dependency Management
Adding Dependencies
Dependencies go in build.gradle.kts inside the sourceSets block:
kotlin {
sourceSets {
// Dependencies for ALL platforms
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
// Android-only dependencies
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.0")
implementation("androidx.activity:activity-compose:1.8.0")
}
// iOS-only dependencies
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.0")
}
// Test dependencies
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
How Dependencies Propagate
Dependencies in commonMain are automatically available in ALL platform source sets:
commonMain: ktor-client-core → available in androidMain AND iosMain
androidMain: ktor-client-okhttp → available ONLY in androidMain
iosMain: ktor-client-darwin → available ONLY in iosMain
This is why Ktor has a core module (common) and engine modules like okhttp (Android) and darwin (iOS). The common code uses the core API, and each platform plugs in its own HTTP engine.
Practical Example: Building a Complete Shared Module
Let’s put it all together. Here is a shared module with a data model, repository, platform utilities, and tests:
Data Model (commonMain)
// shared/src/commonMain/kotlin/models/Task.kt
data class Task(
val id: String,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val createdAt: Long = currentTimeMillis()
) {
fun isValid(): Boolean = title.isNotBlank()
fun toggleCompleted(): Task = copy(isCompleted = !isCompleted)
fun summary(): String {
val status = if (isCompleted) "Done" else "Pending"
return "[$status] $title"
}
}
Platform Utilities (expect/actual)
// shared/src/commonMain/kotlin/utils/Platform.kt
expect fun currentTimeMillis(): Long
expect fun generateId(): String
expect fun logMessage(tag: String, message: String)
// shared/src/androidMain/kotlin/utils/Platform.android.kt
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun generateId(): String = java.util.UUID.randomUUID().toString()
actual fun logMessage(tag: String, message: String) {
android.util.Log.d(tag, message)
}
// shared/src/iosMain/kotlin/utils/Platform.ios.kt
actual fun currentTimeMillis(): Long =
(platform.Foundation.NSDate().timeIntervalSince1970 * 1000).toLong()
actual fun generateId(): String =
platform.Foundation.NSUUID().UUIDString()
actual fun logMessage(tag: String, message: String) {
platform.Foundation.NSLog("[$tag] $message")
}
Repository (commonMain)
// shared/src/commonMain/kotlin/repository/TaskRepository.kt
class TaskRepository {
private val tasks = mutableListOf<Task>()
fun addTask(title: String, description: String = ""): Task {
val task = Task(
id = generateId(),
title = title,
description = description
)
if (!task.isValid()) throw IllegalArgumentException("Task title cannot be blank")
tasks.add(task)
logMessage("TaskRepo", "Added task: ${task.title}")
return task
}
fun getAllTasks(): List<Task> = tasks.toList()
fun getActiveTasks(): List<Task> = tasks.filter { !it.isCompleted }
fun getCompletedTasks(): List<Task> = tasks.filter { it.isCompleted }
fun toggleTask(id: String): Task? {
val index = tasks.indexOfFirst { it.id == id }
if (index == -1) return null
val toggled = tasks[index].toggleCompleted()
tasks[index] = toggled
return toggled
}
fun deleteTask(id: String): Boolean {
return tasks.removeAll { it.id == id }
}
fun searchTasks(query: String): List<Task> {
val lower = query.lowercase()
return tasks.filter {
it.title.lowercase().contains(lower) ||
it.description.lowercase().contains(lower)
}
}
fun taskCount(): Int = tasks.size
fun completedCount(): Int = tasks.count { it.isCompleted }
}
This repository uses generateId() and logMessage() — which are expect/actual functions. The repository doesn’t know or care how IDs are generated or how logging works on each platform. It just calls the functions.
Tests (commonTest)
// shared/src/commonTest/kotlin/TaskRepositoryTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TaskRepositoryTest {
@Test
fun addTaskShouldIncreaseCount() {
val repo = TaskRepository()
repo.addTask("Buy groceries")
assertEquals(1, repo.taskCount())
}
@Test
fun toggleTaskShouldChangeStatus() {
val repo = TaskRepository()
val task = repo.addTask("Learn KMP")
assertFalse(task.isCompleted)
val toggled = repo.toggleTask(task.id)
assertNotNull(toggled)
assertTrue(toggled.isCompleted)
}
@Test
fun deleteTaskShouldRemoveIt() {
val repo = TaskRepository()
val task = repo.addTask("Temporary task")
assertTrue(repo.deleteTask(task.id))
assertEquals(0, repo.taskCount())
}
@Test
fun searchShouldFindMatchingTasks() {
val repo = TaskRepository()
repo.addTask("Learn Kotlin")
repo.addTask("Learn Swift")
repo.addTask("Go shopping")
val results = repo.searchTasks("learn")
assertEquals(2, results.size)
}
@Test
fun getActiveTasksShouldExcludeCompleted() {
val repo = TaskRepository()
val task1 = repo.addTask("Task 1")
repo.addTask("Task 2")
repo.toggleTask(task1.id)
assertEquals(1, repo.getActiveTasks().size)
assertEquals(1, repo.getCompletedTasks().size)
}
@Test
fun taskSummaryShouldShowStatus() {
val task = Task(id = "1", title = "Learn KMP")
assertEquals("[Pending] Learn KMP", task.summary())
val completed = task.toggleCompleted()
assertEquals("[Done] Learn KMP", completed.summary())
}
}
These tests run on BOTH Android and iOS. One test file, verified on every platform.
Common Mistakes
Mistake 1: Wrong Source Set
// You wrote this in commonMain but it uses Android APIs
import android.content.Context // ❌ WON'T COMPILE FOR iOS
// Move it to androidMain or use expect/actual
Mistake 2: Circular Dependencies
// commonMain trying to use androidMain code
// ❌ commonMain CANNOT see androidMain
import com.example.android.MyAndroidHelper // ERROR
// ✅ Use expect/actual to expose platform code to common
Mistake 3: Forgetting an actual Declaration
// If you have expect in commonMain
expect fun doSomething(): String
// And actual only in androidMain
actual fun doSomething(): String = "Android"
// But NOT in iosMain — ❌ COMPILATION ERROR
// "Expected function 'doSomething' has no actual declaration in module for iOS"
The compiler forces you to implement every expect on every platform. This prevents runtime crashes.
Mistake 4: Wrong Dependency Scope
// BAD — adding Android library to commonMain
commonMain.dependencies {
implementation("androidx.core:core-ktx:1.10.1") // ❌ Android-only!
}
// GOOD — add to androidMain
androidMain.dependencies {
implementation("androidx.core:core-ktx:1.10.1") // ✅ Correct
}
Mistake 5: Creating Too Many expect/actual Declarations
Before writing expect/actual, check if a KMP library already exists:
// DON'T write expect/actual for dates
expect fun getCurrentDate(): String // Don't do this
// DO use kotlinx-datetime (KMP library)
import kotlinx.datetime.Clock
val now = Clock.System.now() // Works on all platforms
Quick Reference
Source Set Visibility
| Source Set | Can See | Cannot See |
|---|---|---|
| commonMain | commonMain only | androidMain, iosMain |
| androidMain | commonMain + androidMain | iosMain |
| iosMain | commonMain + iosMain | androidMain |
| commonTest | commonMain + commonTest | Platform-specific code |
Build Commands
| Command | What It Does |
|---|---|
./gradlew :shared:allTests | Run tests on all platforms |
./gradlew :shared:testDebugUnitTest | Run tests on Android |
./gradlew :shared:iosSimulatorArm64Test | Run tests on iOS |
./gradlew :composeApp:assembleDebug | Build Android app |
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 | Build iOS framework |
Where to Put Code
| Code Type | Source Set |
|---|---|
| Data models, business logic | commonMain |
| Networking with Ktor | commonMain |
| Database with SQLDelight | commonMain |
| Android Context, SharedPreferences | androidMain |
| UIKit, Foundation (Apple only) | iosMain |
| Apple-wide APIs (iOS + macOS) | appleMain (auto-created) |
| Tests for shared code | commonTest |
Source Code
The base project for this tutorial series is on GitHub:
Related Tutorials
- KMP Tutorial #2: Setting Up Your First Project — create the project before understanding its structure
- KMP Tutorial #1: What is KMP? — the big picture
- Jetpack Compose Tutorial #5: State — state management concepts used in shared ViewModels
What’s Next?
In the next tutorial, we will explore Compose Multiplatform — how to share your UI code across Android, iOS, and Desktop using the same Compose API you already know from our Jetpack Compose series.
See you there.