Kotlin Multiplatform (KMP) Explained
ADVANCED › Emerging
Kotlin Multiplatform lets you write business logic once in Kotlin and run it on Android, iOS, and other targets, instead of writing it twice. The pattern most teams reach for first is called share logic, native UI. Business logic, networking calls, data models, and persistence code all live in a module called commonMain, compiled for every platform. The UI layer stays separate: Jetpack Compose on Android, SwiftUI on iOS. That split is not arbitrary. Each OS has its own navigation conventions, accessibility APIs, and platform look and feel, so UI code diverges in ways that are expensive to unify. Logic, models, and networking rarely diverge that way, so that is where the reuse actually pays off. When an interviewer asks what belongs in commonMain, they are testing whether you understand this boundary, not just whether you know the keyword.
// commonMain, shared across every platform
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String) = api.fetchUser(id)
}
Sometimes code in commonMain needs a platform-specific implementation: reading the OS version, or accessing a native API that only exists on one target. Kotlin's mechanism for this is expect and actual. You declare the shape of an API in commonMain using expect, with no body at all, just a signature. Each platform source set then supplies a matching implementation using the other keyword. The compiler enforces the pairing: if androidMain or iosMain is missing that implementation for some expect declaration it needs to compile, the build fails. This works for functions, classes, objects, properties, and type aliases, not just top level functions.
expect and actual is not the only way to vary behavior per platform, and for many teams it is not even the first choice. A common alternative is to declare a plain interface in commonMain, then supply a platform-specific implementation through dependency injection, Koin is popular in KMP projects. The interface has no special keyword and no compiler-enforced pairing. Teams often prefer this because it is more testable, you can inject a fake implementation in tests, and it avoids the tighter coupling that expect and actual creates between a declaration and its platform implementations.
// commonMain
interface Analytics {
fun track(event: String)
}
// androidMain
class FirebaseAnalyticsImpl(private val ctx: Context) : Analytics {
override fun track(event: String) { /* log to Firebase */ }
}
KMP organizes code into source sets. commonMain and commonTest hold shared code, and platform sets like androidMain and iosMain hold platform-specific code. Historically, if you wanted code shared across all three iOS targets, arm64, x64, and the simulator, but not shared with Android, you had to wire up an intermediate source set by hand. Modern Kotlin ships a default hierarchy template that does this automatically: when your project declares iosArm64, iosX64, and iosSimulatorArm64, Kotlin auto-creates an iosMain source set above them, with no manual sourceSets block needed.
// build.gradle.kts
kotlin {
androidTarget()
iosArm64()
iosX64()
iosSimulatorArm64()
// iosMain is generated automatically above these three targets
}
Each platform compiles the shared Kotlin through a different backend, and this is worth knowing precisely because interviewers use it to check whether you understand KMP is not just a copy-paste trick. Android uses Kotlin or JVM: commonMain code compiles to JVM bytecode alongside the rest of the Android app, with the full Android SDK visible from androidMain. iOS uses Kotlin or Native: the compiler runs the code through LLVM and produces an actual native binary, not bytecode, no JVM involved at all. That binary gets packaged as an Objective-C framework, which is what a Swift project imports. There is no transpilation to Swift and no JavaScript engine anywhere in this path, the Kotlin becomes native machine code.
// Swift, importing the compiled framework
import shared // Kotlin/Native output: commonMain + iosMain
let repo = UserRepository()
// No JVM, no transpiled Swift source, just a native binary behind an Obj-C interface
KMP is not limited to Android and iOS. When a shared module targets the browser, neither the JVM backend nor Kotlin or Native is involved, because neither a JVM nor a native binary makes sense inside a browser tab. Two backends handle the web instead. Kotlin or JS has been the mature option for years: it emits plain JavaScript, which suits teams already comfortable with the JS ecosystem and its tooling. Kotlin or Wasm compiles to WebAssembly instead, generally executing faster and closer in performance to the JVM and Native backends, and it is the newer option Kotlin's team is actively investing in. Either way, the same commonMain business logic reaches the browser without being rewritten.
kotlin {
js(IR) { browser() } // Kotlin/JS: emits JavaScript
wasmJs { browser() } // Kotlin/Wasm: emits WebAssembly
}
For networking, KMP apps typically reach for Ktor Client, a multiplatform HTTP library. The request-building code, headers, query params, the call itself, is written once in commonMain. What differs per platform is the underlying transport, and Ktor handles that by abstracting it behind a pluggable engine: OkHttp on Android, Darwin on iOS, CIO as a pure-Kotlin fallback. You configure the engine per platform, usually with expect and actual or DI, and everything above that layer, the actual API calls, is shared.
// commonMain, one client, engine injected per platform
val client = HttpClient(httpClientEngine) {
install(ContentNegotiation) { json() }
}
suspend fun fetchUser(id: String): User =
client.get("https://api.example.com/users/$id").body()
// androidMain
actual val httpClientEngine: HttpClientEngine = OkHttp.create()
// iosMain
actual val httpClientEngine: HttpClientEngine = Darwin.create()
Ktor's ContentNegotiation plugin pairs with kotlinx.serialization for JSON, and that pairing is not arbitrary, it is close to the only realistic option once Kotlin or Native is in play. Gson and Moshi both work by using JVM reflection at runtime to inspect a class's fields and build or read JSON from them. Kotlin or Native has no JVM and no runtime reflection to lean on, so both libraries simply do not work there. kotlinx.serialization avoids the problem entirely: a compiler plugin generates a serializer for every class marked Serializable at compile time, so encoding and decoding call generated code instead of inspecting anything at runtime. That is why it is the default JSON library across every KMP target, JVM, Native, and JS alike, while Gson and Moshi stay JVM-only.
@Serializable
data class User(val id: String, val name: String)
val json = Json.encodeToString(User("1", "Alice"))
val user = Json.decodeFromString<User>(json)
For local persistence, SQLDelight is the common KMP choice. You write plain SQL in dot sq files, table definitions and named queries, and SQLDelight generates type-safe Kotlin functions for each query at compile time. There is no reflection and no runtime schema parsing involved, the generated code is just as concrete as if you had hand-written it. What differs per platform is the driver that actually talks to SQLite: AndroidSqliteDriver on Android, NativeSqliteDriver on iOS, both backing the same generated queries. Room has more recently become a KMP-ready alternative, but SQLDelight remains the longer-established option and the one interviewers expect you to know.
-- User.sq
CREATE TABLE User (id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL);
selectAll:
SELECT * FROM User;
Shipping shared Kotlin to iOS means it arrives as an Objective-C framework, and that bridge is where the real friction lives. Objective-C has no concept of a Kotlin coroutine, so a suspend function does not become a Swift async function automatically, it surfaces as a completion handler instead, with an extra callback parameter and none of Swift's structured concurrency. Generics and sealed classes cross the same bridge imperfectly too, since Objective-C's type system is considerably weaker than Kotlin's. A library called SKIE has become the standard fix: it post-processes the generated framework to restore proper Swift async and await and produce friendlier Swift types for sealed classes and generics.
// commonMain
suspend fun loadUser(id: String): User
// Swift, without SKIE
SharedKt.loadUser(id: "42") { user, error in
guard let user else { return }
print(user.name)
}
Older Kotlin or Native had a strict rule that tripped up a lot of teams: any mutable object shared across threads had to be explicitly frozen first by calling freeze, and mutating a frozen object afterward crashed at runtime with an exception. That model is gone. The modern Kotlin or Native memory manager, standard since Kotlin 1.7.20, dropped the freezing requirement entirely, mutable state now moves across threads much like it does on the JVM. This is worth knowing precisely because it is a common interview trap: describing the old freezing model as how things work today signals your KMP knowledge is stale. If you have hit an InvalidMutabilityException in a real project, that is worth mentioning, but frame it as a problem the current memory manager already solved, not as current behavior.
// Modern Kotlin/Native, no freeze() call needed
val counter = AtomicInt(0)
withContext(Dispatchers.Default) {
counter.incrementAndGet()
}
Sometimes shared code needs something no Kotlin library provides: direct access to an existing C or Objective-C library, SQLite's own C API, say, or a vendor SDK shipped as a framework. Kotlin or Native ships a tool for exactly this. You describe the library in a dot def file that points at its headers, register that in the Gradle build, and the compiler generates Kotlin bindings you can call like any other API. There is no JVM and no JNI anywhere in this path, the generated bindings call straight into the native library. This is also how Apple's own frameworks, UIKit and CoreLocation among them, get exposed to iosMain in the first place.
// 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)
Once shared Kotlin or Native code compiles, it still has to reach an actual Xcode project, and that packaging step is separate from the Objective-C interop bridge itself. The standard artifact is an XCFramework: a single framework bundle that covers every iOS architecture at once, device and simulator, arm64 and x64, together. Gradle's XCFramework helper builds it from your iosArm64, iosX64, and iosSimulatorArm64 targets in a single task. From there, the iOS team consumes Shared.xcframework however the rest of their dependencies are already managed: dragging it directly into Xcode, distributing it as a CocoaPod, or publishing it through Swift Package Manager.
kotlin {
val xcf = XCFramework("Shared")
listOf(iosArm64(), iosX64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "Shared"
xcf.add(this)
}
}
}
// ./gradlew assembleSharedXCFramework
So when is KMP actually worth adopting? It pays off when two or more platforms share substantial business logic, networking, and data layers, the kind of code you would otherwise be maintaining twice. Adoption can be incremental too, you do not have to rewrite an existing app to start sharing a single repository or use case. The cost is real, though: iOS work still needs a Mac running Xcode, Gradle has to produce and integrate an iOS framework, Kotlin or Native compiles are noticeably slower than plain JVM ones, and debugging across the Kotlin and Swift boundary is harder than debugging either language alone. A single-platform app, or one whose code is almost entirely platform-specific UI with trivial logic, gains little from any of this. Most teams start with logic-only sharing and keep native UI, but if you do want to share UI too, Compose Multiplatform, JetBrains' UI framework built on Jetpack Compose, extends across Android, iOS, desktop, and web, with iOS support now Stable. It is optional, and adopting it is a separate decision from adopting KMP for logic sharing in the first place.