Kotlin Multiplatform
KMP lets one Kotlin source set compile to Android bytecode and to native iOS code. It is not a UI framework and not a rewrite; it is a way to stop writing the same business rules twice.
Kotlin Multiplatform is the most over-promised and under-explained feature in the Kotlin ecosystem. Stripped of marketing, it is one idea: a Kotlin source set with no platform-specific imports can be compiled for several targets, and each target gets a native or JVM artifact.
TL;DR#
- commonMain holds platform-free Kotlin: models, validation, business rules, suspend functions.1
- Each target compiles it differently: Kotlin/JVM → DEX for Android, Kotlin/Native for iOS.
- expect/actual declares a platform-dependent API in common code and implements it per target.2
- The hard constraint is libraries: a dependency used in common code must itself be multiplatform.
- UI is usually not shared. Sharing business logic is the established pattern; sharing UI is possible but is a much bigger commitment.
- For an Android developer, the practical value is usually a shared module with a backend or an iOS app, not a way to avoid Android work.
Mental model#
Think of a company with offices in three countries that shares one employee handbook.
The handbook is commonMain: policies that apply everywhere, written in a language all three
offices read. It cannot reference local labour law, because the other two offices do not have it.
Each office writes a short local appendix — androidMain, iosMain — that fills in the parts
the handbook deliberately left open. The handbook says "use the local holiday calendar"; the appendix
says which calendar.
The compiler is the HR department that checks every appendix actually exists. If a target has no implementation of a declared policy, that target fails to build.
Where the analogy breaks#
An appendix in one office can be very different in size. The Android actual might be three lines; the iOS actual might be three hundred, because the platform APIs differ that much. expect/actual is a contract about existence, not about effort.
The big picture#
Text description of this diagram
One shared source set, commonMain, holding pure Kotlin with models, validation, business rules and suspend functions and no platform imports. It feeds three targets. Android compiles via Kotlin/JVM to DEX, with an androidMain source set and an actual function returning the SDK level. iOS compiles via Kotlin/Native to a framework that Xcode links, with an iosMain source set and an actual returning the device system name. JVM server compiles via Kotlin/JVM, with a jvmMain source set. Four rules sit below: commonMain cannot see platform APIs, expect declares and actual implements, libraries must be multiplatform, and UI is usually not shared.
The rule list at the bottom is the one to internalise. Every KMP migration failure is one of those four rules being discovered late.
Deep dive#
The layout#
A KMP module is a Gradle module with a multiplatform plugin:
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
androidMain.dependencies {
implementation("androidx.core:core-ktx:1.13.1")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}The directory structure follows the source set names:
shared/
src/
commonMain/kotlin/com/example/shared/Profile.kt
androidMain/kotlin/com/example/shared/Platform.android.kt
iosMain/kotlin/com/example/shared/Platform.ios.kt
jvmMain/kotlin/com/example/shared/Platform.jvm.kt
commonTest/kotlin/com/example/shared/ProfileTest.ktexpect/actual#
An expect declaration says "this exists, and each target provides it". An actual provides it.
// commonMain
expect fun platformName(): String
expect class TimeSource() {
fun nowMillis(): Long
}// androidMain
actual fun platformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
actual class TimeSource {
actual fun nowMillis(): Long = System.currentTimeMillis()
}// iosMain
actual fun platformName(): String = platform.UIKit.UIDevice.currentDevice.systemName
actual class TimeSource {
private val start = kotlinx.datetime.Clock.System.now()
actual fun nowMillis(): Long = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()
}The iOS actual uses kotlinx.datetime rather than Foundation, because Kotlin/Native interop with
Objective-C frameworks is verbose and best kept in a thin layer.
The library problem#
The most common first KMP failure: a dependency in commonMain is JVM-only.
// commonMain — fails for iOS
implementation("com.squareup.okhttp3:okhttp:4.12.0")OkHttp is a JVM library. Ktor is the multiplatform HTTP client; kotlinx.serialization is the multiplatform serialization library. The rule is simple and brutal: anything in commonMain must publish a KMP artifact for every target you declare.
This is why KMP adoption is easier for teams already using Ktor and kotlinx libraries, and harder for teams deep in a JVM-only dependency graph.
Concurrency across targets#
Coroutines work on every KMP target, but the dispatchers differ.3
- Android has
Dispatchers.Mainbacked by the mainLooper. - iOS has
Dispatchers.Mainbacked by the main queue. - The JVM has no main dispatcher unless one is provided.
If commonMain schedules work on Dispatchers.Main, it assumes the host provides one. That is
usually fine, but it means a shared module tested only on Android may behave differently on a
server where no main dispatcher exists.
What Google supports#
Google's support for KMP has grown from "usable" to "supported in AndroidX", with a growing set of AndroidX libraries offering KMP artifacts. The framing matters: KMP is supported as a way to share code from Android, not as a replacement for Android development.4
A growing subset of AndroidX libraries publishes Kotlin Multiplatform artifacts.
unverified(the exact set of AndroidX libraries publishing KMP artifacts changes with each release; check the AndroidX release notes for the current list)Should an Android developer care?#
Three honest answers:
- If you have an iOS app: yes. Sharing models, validation and networking logic is the strongest case, and it is what most KMP production users actually do.
- If you have a backend in Kotlin: yes, for the same reason. The shared module runs on the server too, and the business rules stop diverging.
- If you only have an Android app: probably not yet. The cost is extra build complexity and a dependency constraint on every library you use, and the benefit is zero until there is a second target.
That last point is the one the marketing tends to skip. KMP's value is proportional to how many targets you actually ship.
Writing commonMain that stays common#
The discipline that makes KMP work is refusing to let platform assumptions into the shared module. Three habits do most of it.
Inject the clock and the dispatcher. System.currentTimeMillis() is JVM-only. Pass a Clock
and a CoroutineDispatcher in from the platform, or use a multiplatform abstraction, rather than
calling a platform API from common code.
// commonMain
class SessionValidator(
private val clock: Clock,
private val dispatcher: CoroutineDispatcher,
) {
suspend fun isExpired(session: Session): Boolean = withContext(dispatcher) {
clock.now() > session.expiresAt
}
}Model errors as values, not exceptions. Kotlin/Native and the JVM disagree about some exception
semantics, and a Result-shaped return type is easier to test in common code than a try/catch
around platform calls.
Keep the expect surface tiny. Every expect is a place where the shared code stops being
shared. A module with three expects is a shared library; a module with thirty is an interface
definition with three implementations, and the sharing benefit has largely evaporated.
Testing shared code#
The commonTest source set runs on every target, which is the quiet superpower of KMP.1 A test
written once verifies the same business rule on Android, iOS and the JVM.
// commonTest
class SessionValidatorTest {
@Test
fun expiredSessionIsRejected() = runTest {
val clock = FakeClock(now = 1_000)
val validator = SessionValidator(clock, StandardTestDispatcher(testScheduler))
val session = Session(expiresAt = 500)
assertTrue(validator.isExpired(session))
}
}That test is worth more than three platform-specific tests, because it verifies the rule that all
three apps depend on. The platform-specific tests still matter for the actual implementations, but
they are testing a thin adapter rather than the logic.
The adoption path that actually works#
Teams that succeed with KMP tend to follow the same sequence, and teams that fail tend to skip to the end.
- Extract one pure module. Pick something with no platform dependencies — date formatting, a
validation rule, a pricing calculation — and move it to
commonMainwith a single Android target. This proves the build works before it proves the concept. - Add the second target. Only when the first module builds and its tests run on both. This is where the library constraint bites, and it is better to discover it on one module than on ten.
- Move networking and models. This is the highest-value step, because models and API contracts are where divergence between Android and iOS hurts most.
- Leave UI alone until there is a specific reason to change it.
The failure mode is starting with UI because it is the visible part. Compose Multiplatform is real, but it is a much larger commitment than sharing a data class, and it does not solve the problem most teams actually have, which is two implementations of the same business rule drifting apart.
The honest cost table#
Before adopting KMP, price these, because they are paid by the whole team and not just the module's author.
| Cost | Why it appears |
|---|---|
| A second build target in CI | Every target must compile and test, and iOS needs a macOS runner |
| Library vetting | Every shared dependency needs a KMP artifact |
| Tooling friction | Debugging a Kotlin/Native crash is not like debugging on the JVM |
| Team knowledge | expect/actual, source-set layout and multiplatform Gradle are new concepts |
| Slower iteration | A change to commonMain rebuilds every target |
The benefit — one implementation of a business rule instead of two or three — is real and often large. The mistake is judging the benefit on the first week, when the cost is fully paid and the benefit has not yet accumulated.
How it evolved#
Under the hood#
- the Kotlin compiler, which contains the multiplatform backends
- the library that makes common concurrency possible
- how the platform itself compiles Kotlin for Android targets
Curiosity corner#
- Kotlin/Native has a different memory model from the JVM. The old strict model was relaxed, but object sharing across threads still has rules that do not exist on Android.
- The shared module is usually a library, not an application. The Android app and the iOS app each keep their own entry point and UI.
- Compose Multiplatform exists and can share UI, but it is a separate decision with its own trade-offs; sharing logic is the safer default.
Try it#
./gradlew :shared:tasks --all | grep -i compileKotlin
Example output
> Task :shared:tasks
compileKotlinAndroid
compileKotlinIosArm64
compileKotlinJvm./gradlew :shared:jvmTest
Example output
> Task :shared:jvmTest
BUILD SUCCESSFULBuild it#
Create a shared module with commonMain holding one data class and one validation function, then
call it from the Android app. That is the smallest useful KMP setup, and it costs one Gradle plugin
and one directory.
Check yourself#
Check yourself
Why did Android choose the Java language but not the JVM?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsKotlin Multiplatformkotlinlang.orgaccessed 2026-10-27↩
- docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
- docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩