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

  1. Every expect must have an actual on every platform. If you forget the iOS implementation, the project won’t compile.

  2. The signatures must match exactly. Same name, same parameters, same return type.

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

NeedDon’t Use expect/actualUse This Instead
HTTP requestsPlatform HTTP clientsKtor Client
DatabasePlatform databasesSQLDelight or Room
JSON parsingPlatform JSON libsKotlin Serialization
Key-value storageSharedPreferences/NSUserDefaultsDataStore
DIPlatform DI frameworksKoin
Date/timePlatform date APIskotlinx-datetime
CoroutinesPlatform threadingkotlinx-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 SetCan SeeCannot See
commonMaincommonMain onlyandroidMain, iosMain
androidMaincommonMain + androidMainiosMain
iosMaincommonMain + iosMainandroidMain
commonTestcommonMain + commonTestPlatform-specific code

Build Commands

CommandWhat It Does
./gradlew :shared:allTestsRun tests on all platforms
./gradlew :shared:testDebugUnitTestRun tests on Android
./gradlew :shared:iosSimulatorArm64TestRun tests on iOS
./gradlew :composeApp:assembleDebugBuild Android app
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Build iOS framework

Where to Put Code

Code TypeSource Set
Data models, business logiccommonMain
Networking with KtorcommonMain
Database with SQLDelightcommonMain
Android Context, SharedPreferencesandroidMain
UIKit, Foundation (Apple only)iosMain
Apple-wide APIs (iOS + macOS)appleMain (auto-created)
Tests for shared codecommonTest

Source Code

The base project for this tutorial series is on GitHub:

View source code on GitHub →

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.