Kotlin Multiplatform Mobile: Sharing Code Across iOS and Android
Kotlin Multiplatform (KMP) lets you write shared business logic once and run it on both iOS and Android while keeping native UI on each platform. Unlike Flutter or React Native which replace native UI frameworks, KMP shares only the code that should be shared — networking, data processing, state management, business rules — and lets each platform handle its own UI with SwiftUI or Jetpack Compose. This pragmatic approach has made the technology the fastest-growing cross-platform solution since Google officially endorsed it in 2024. Crucially, the shared code compiles to a real native binary on each target: a JVM library on Android and an Objective-C-compatible framework on iOS, so there is no JavaScript bridge or embedded runtime to ship.
Why KMP Over Flutter or React Native
The key difference is philosophy. Flutter and React Native own the entire stack — UI, navigation, gestures, animations. This means you are always one step behind native platform features and fighting framework limitations for platform-specific behavior. KMP takes the opposite approach: share what is genuinely shareable (networking, persistence, validation, analytics), keep what is platform-specific native (UI, permissions, hardware access).
Furthermore, KMP does not require your entire team to adopt a new framework. iOS developers continue writing SwiftUI, Android developers continue writing Jetpack Compose. The shared module is just a library that both platforms consume. As a result, adoption is gradual — you can start with one shared module and expand over time, rather than committing to a rewrite up front.
There is also a hiring dimension that teams often overlook. Because the shared layer is plain Kotlin, your existing Android engineers can write it immediately, and iOS engineers integrate it the same way they would any third-party XCFramework. By contrast, a Flutter or React Native rewrite typically demands a dedicated specialist team and leaves your platform-native experts underused.
Project Structure and Setup
// build.gradle.kts (shared module)
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("app.cash.sqldelight:runtime:2.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.8")
implementation("app.cash.sqldelight:android-driver:2.0.1")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.8")
implementation("app.cash.sqldelight:native-driver:2.0.1")
}
}
}
Notice the source-set hierarchy: commonMain holds the shared logic, while androidMain and iosMain supply only the platform-specific dependencies. The Kotlin compiler resolves which engine to link at build time, so common code never references a platform type directly. This separation is what keeps the shared layer truly portable.
The Expect/Actual Pattern
When shared code needs platform-specific implementations, KMP uses the expect/actual mechanism. You declare an expected interface in common code, then provide actual implementations for each platform. Think of expect as an abstract declaration the compiler will refuse to build until every target supplies its matching actual.
// commonMain - Declare expected platform behavior
expect class PlatformContext
expect fun createDatabaseDriver(context: PlatformContext): SqlDriver
expect fun getDeviceInfo(): DeviceInfo
data class DeviceInfo(
val platform: String,
val osVersion: String,
val deviceModel: String,
val appVersion: String
)
// androidMain - Android implementation
actual typealias PlatformContext = android.content.Context
actual fun createDatabaseDriver(context: PlatformContext): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
actual fun getDeviceInfo(): DeviceInfo = DeviceInfo(
platform = "Android",
osVersion = "Android ${android.os.Build.VERSION.RELEASE}",
deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}",
appVersion = BuildConfig.VERSION_NAME
)
// iosMain - iOS implementation
actual class PlatformContext // No-op on iOS
actual fun createDatabaseDriver(context: PlatformContext): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
actual fun getDeviceInfo(): DeviceInfo {
val device = UIDevice.currentDevice
return DeviceInfo(
platform = "iOS",
osVersion = "iOS ${device.systemVersion}",
deviceModel = device.model,
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as String
)
}
A common beginner mistake is overusing expect/actual. If only one line differs between platforms, prefer dependency injection of a small interface rather than splitting an entire class. In practice, teams reserve expect/actual for genuine platform primitives — database drivers, secure storage, biometric prompts — and keep everything else in pure common code where it can be unit-tested once.
Networking with Ktor Client
Ktor is the recommended HTTP client for KMP. It provides a common API across platforms while using platform-native engines underneath (OkHttp on Android, URLSession on iOS via Darwin). Because the engine is native, you inherit the platform’s connection pooling, TLS stack, and system proxy settings for free.
// commonMain - Shared API client
class ApiClient(private val httpClient: HttpClient) {
suspend fun getProducts(): List<Product> {
return httpClient.get("https://api.example.com/products")
.body()
}
suspend fun createOrder(order: OrderRequest): OrderResponse {
return httpClient.post("https://api.example.com/orders") {
contentType(ContentType.Application.Json)
setBody(order)
}.body()
}
companion object {
fun create(): ApiClient {
val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = false
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
install(Logging) {
level = LogLevel.HEADERS
}
}
return ApiClient(client)
}
}
}
One important edge case: Kotlin coroutines do not map cleanly to Swift’s async/await until you cross the boundary deliberately. Suspend functions are exposed to Swift as completion handlers by default, so most teams wrap the shared API in a thin Flow-to-AsyncSequence adapter or use the SKIE plugin to generate idiomatic Swift signatures. Plan for this glue code early, because it shapes how natural the API feels on the iOS side.
Persistence with SQLDelight
SQLDelight generates type-safe Kotlin APIs from SQL statements. It works across all KMP targets and provides compile-time verification of your queries — a typo in a column name fails the build rather than crashing at runtime.
-- shared/src/commonMain/sqldelight/com/example/ProductQueries.sq
CREATE TABLE Product (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT NOT NULL,
inStock INTEGER AS Boolean NOT NULL DEFAULT 1,
lastUpdated TEXT NOT NULL
);
getAll:
SELECT * FROM Product ORDER BY name;
getByCategory:
SELECT * FROM Product WHERE category = ? AND inStock = 1;
search:
SELECT * FROM Product WHERE name LIKE '%' || ? || '%';
upsert:
INSERT OR REPLACE INTO Product(id, name, price, category, inStock, lastUpdated)
VALUES (?, ?, ?, ?, ?, ?);
Testing the Shared Module
One of the strongest arguments for KMP is that business logic gets tested once. Tests placed in commonTest run against every target, so a validation rule verified on the JVM is guaranteed to behave identically on the iOS native binary. Teams typically combine fast JVM-backed unit tests for logic with a smaller set of iOS-target tests that exercise the actual Darwin engine and SQLite driver.
For shared coroutine code, the kotlinx-coroutines-test library provides a virtual-time test dispatcher so suspending functions resolve deterministically without real delays. Mocking the HttpClient with Ktor’s MockEngine lets you assert request bodies and simulate error responses entirely in common code, which keeps the test suite portable and fast.
When NOT to Use KMP, and the Trade-offs
KMP is not free of friction, and being honest about that matters. The iOS toolchain is the weak point: build times for the native framework are slower than a pure Swift build, debugging Kotlin/Native from Xcode is awkward, and stack traces can be hard to read. If your app is almost entirely UI with little shared logic — a thin client over a few REST calls — the sharing payoff may not justify the added Gradle and interop complexity.
Similarly, avoid KMP when your team has no Kotlin experience at all, or when you need a single-codebase UI to ship a prototype in days; Flutter is genuinely faster for that. The pattern shines for apps with substantial domain logic — fintech rules, sync engines, offline-first data layers — where correctness must match exactly across platforms. For deeper mobile structure guidance, see the related discussion of mobile app architecture patterns and the comparison in Kotlin Multiplatform vs Flutter and React Native.
Migration Strategy: Native to KMP
The recommended migration path: start with a new shared module containing one feature (usually networking or analytics). Keep all existing native code. Gradually move business logic to shared code over multiple releases. Never migrate UI — keep it native. This approach de-risks the migration and lets teams build confidence before committing fully. Industry reports commonly cite 30-40% code sharing after the first phase and 50-60% after fully migrating business logic, though the exact figure depends heavily on how UI-heavy the app is.
Key Takeaways
For further reading, refer to the Android developer docs and the Apple developer docs for comprehensive reference material.
- Share business logic and data layers; keep UI native with SwiftUI and Jetpack Compose
- Reserve expect/actual for genuine platform primitives, not minor differences
- Plan the Kotlin-to-Swift interop boundary early, especially for coroutines and Flow
- Write logic tests once in commonTest so behavior is identical across targets
- Adopt incrementally — one shared module at a time, never a big-bang rewrite
For teams already using Kotlin on Android, the learning curve is minimal — and iOS developers appreciate that the approach does not replace their tools. Start with networking and data layers, expand to state management and business rules, and keep UI native.
In conclusion, Kotlin Multiplatform Mobile is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.