Kotlin Multiplatform (KMP) Explained
ADVANCED › Emerging
**Kotlin Multiplatform (KMP)** lets you write business logic once in Kotlin and run it on Android, iOS, and beyond, without rewriting it per platform. The most common shape teams reach for is "share logic, keep UI native": commonMain holds business logic, networking, data models, persistence, and often ViewModels, while each platform keeps its own UI, Jetpack Compose on Android, SwiftUI on iOS.
// commonMain, shared across every platform
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String) = api.fetchUser(id)
}
The UI layer stays platform-specific because it's where each OS's look, navigation, and accessibility conventions genuinely diverge. Logic, models, and networking rarely do, that's the reuse KMP is built for.
When common code needs a platform-specific implementation, Kotlin gives you expect or actual. You declare the API's shape in commonMain with expect and no body, then each platform source set supplies a matching actual implementation:
// commonMain
expect fun platformName(): String
// androidMain
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
actual fun platformName(): String = UIDevice.currentDevice.systemName()
The compiler enforces that every expect declaration has a matching actual in each platform source set that compiles it, functions, classes, objects, properties, and typealiases can all be declared this way.
expect or actual isn't the only way to vary behavior per platform, and it's often not the first choice. A common modern alternative is a plain interface in commonMain, with a platform-specific implementation injected via DI (Koin is popular in KMP):
// commonMain, plain interface, no expect/actual
interface Analytics {
fun track(event: String)
}
// androidMain
class FirebaseAnalyticsImpl(private val ctx: Context) : Analytics {
override fun track(event: String) { /* log to Firebase */ }
}
Both work for functions, classes, and properties, so the choice usually comes down to workflow rather than capability.
KMP organizes code into **source sets**: commonMain or commonTest for shared code, plus platform sets like androidMain and iosMain. Modern Kotlin's **default hierarchy template** auto-creates intermediate source sets for a target family, so iosMain sits above iosArm64, iosX64, and iosSimulatorArm64 without you wiring sourceSets { ... } by hand.
Each platform compiles through a different backend: Android uses **Kotlin or JVM**, with the full Android SDK available in androidMain. iOS uses **Kotlin or Native**, which compiles Kotlin straight to a native binary via LLVM. Web targets use **Kotlin or Wasm** or **Kotlin or JS**. There's no JVM and no transpilation on iOS, it's genuinely native code.
For networking, KMP apps typically reach for **Ktor Client**, a multiplatform HTTP client that abstracts the actual transport behind a pluggable *engine*. You write the request logic once in commonMain and inject a different engine per platform:
// commonMain
val client = HttpClient(httpClientEngine) {
install(ContentNegotiation) { json() }
}
// androidMain
actual val httpClientEngine: HttpClientEngine = OkHttp.create()
Ktor pairs with kotlinx.serialization's ContentNegotiation plugin for JSON, so the request and parsing code is shared, only the underlying socket implementation swaps per platform.
Ktor's ContentNegotiation plugin pairs with **kotlinx.serialization** for JSON, and that pairing isn't arbitrary, it's the only realistic choice on Kotlin/Native. Gson and Moshi both lean on JVM reflection to inspect a class's fields at runtime and build or read JSON from them, and Kotlin/Native has no JVM and no runtime reflection to lean on. kotlinx.serialization sidesteps that entirely: a compiler plugin generates a serializer() for every @Serializable class at compile time, so encoding and decoding never inspect anything at runtime, they just call generated code:
@Serializable
data class User(val id: String, val name: String)
val json = Json.encodeToString(User("1", "Alice"))
val user = Json.decodeFromString<User>(json)
That's why it's the default JSON library across every KMP target, JVM, Native, and JS alike, while Gson and Moshi stay JVM-only.
For local persistence, **SQLDelight** is the common KMP choice. You write plain SQL in .sq files, and it generates type-safe Kotlin query functions at compile time, no reflection, no runtime schema parsing:
-- User.sq
CREATE TABLE User (id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL);
selectAll:
SELECT * FROM User;
Each platform supplies its own SqlDriver, AndroidSqliteDriver on Android, NativeSqliteDriver on iOS, backing the same generated queries. Room has also become a KMP-ready alternative more recently, but SQLDelight remains the longer-standing option.
Shipping shared Kotlin to iOS means Kotlin or Native compiles it into an **Objective-C framework** that Swift imports. That bridge is where the friction lives: Objective-C has no concept of Kotlin coroutines, so a suspend fun doesn't become a Swift async function automatically:
// commonMain
suspend fun loadUser(id: String): User
// Swift, without any extra tooling
SharedKt.loadUser(id: "42") { user, error in
guard let user else { return }
print(user.name)
}
Generics and sealed classes map imperfectly across the same bridge. A library called **SKIE** post-processes the generated framework to restore native async or await and friendlier Swift types.
Older Kotlin/Native had a strict rule that tripped up a lot of teams: any mutable object shared across threads had to be explicitly **frozen** first with .freeze(), and mutating a frozen object crashed at runtime. That model is gone. The modern Kotlin/Native memory manager, standard since Kotlin 1.7.20, drops the freezing requirement entirely, mutable state can now move across threads much like it does on the JVM:
// Modern Kotlin/Native — no freeze() needed
val counter = AtomicInt(0)
withContext(Dispatchers.Default) {
counter.incrementAndGet()
}
This matters for an interview answer because it's a common trap: describing the old freezing model as current knowledge signals your KMP experience is stale. If you've hit InvalidMutabilityException in a real project, that's worth mentioning, but frame it as a problem the current memory manager already solved.
The source-sets chunk mentioned web targets in half a sentence, they deserve more. When a KMP module targets the browser, there's no JVM and no Kotlin/Native binary involved, the shared code compiles through one of two backends instead. **Kotlin/JS** emits JavaScript and has been the mature option for years, well-suited to teams already comfortable with the JS ecosystem and interop. **Kotlin/Wasm** compiles to WebAssembly instead, generally faster to execute and closer in performance to the Native and JVM backends, and it's the newer, actively-invested-in option:
kotlin {
js(IR) { browser() } // Kotlin/JS — emits JavaScript
wasmJs { browser() } // Kotlin/Wasm — emits WebAssembly
}
Either way, the same commonMain business logic reaches the browser without a rewrite.
Sometimes shared code needs something no Kotlin library provides, direct access to an existing C or Objective-C library, SQLite's C API, say, or a vendor SDK shipped as a .framework. Kotlin/Native ships a tool for exactly this: **cinterop**. You describe the library in a .def file pointing at its headers, register it in the Gradle build, and the compiler generates Kotlin bindings you call like any other API:
// build.gradle.kts
kotlin {
iosArm64 {
compilations["main"].cinterops { val sqlite3 by creating }
}
}
// iosMain, using the generated bindings
import platform.sqlite3.*
sqlite3_open(":memory:", db.ptr)
There's no JVM and no JNI involved, the generated bindings call straight into the native library, which is also how Apple's own frameworks, like UIKit and CoreLocation, are exposed to iosMain in the first place.
Once shared Kotlin/Native code compiles, it still has to reach an actual Xcode project, and that's a packaging question separate from the Objective-C interop bridge you already saw. The standard artifact is an **(XC)Framework**: a single framework bundle covering all your iOS architectures, device and simulator, arm64 and x64, together. Gradle's XCFramework helper builds it from your iosArm64, iosX64, and iosSimulatorArm64 targets in one task:
kotlin {
val xcf = XCFramework("Shared")
listOf(iosArm64(), iosX64(), iosSimulatorArm64()).forEach {
it.binaries.framework { baseName = "Shared"; xcf.add(this) }
}
}
From there the iOS team consumes Shared.xcframework three ways: dragging it directly into Xcode, distributing it as a CocoaPod, or publishing it through Swift Package Manager, whichever already matches how the rest of their dependencies are managed.
So when is KMP worth it? It pays off when two or more platforms share **substantial** business logic, networking, and data layers, adoption can be incremental (you don't have to rewrite an app to start), and Kotlin or Native gives near-native performance.
The cost is real: iOS work still needs a Mac with Xcode, Gradle has to produce and integrate an iOS framework, Kotlin or Native compiles are slower than JVM ones, and debugging across the Kotlin or Swift boundary is harder than debugging either language alone. Optionally, **Compose Multiplatform** (JetBrains' UI framework, with Stable iOS support) lets you share UI too, but most teams start with logic-only sharing and keep native UI.