Kotlin Multiplatform Production Guide: Sharing Code Between Android and iOS
Kotlin Multiplatform production development has matured into a reliable strategy for sharing business logic between Android and iOS applications. Unlike cross-platform UI frameworks (Flutter, React Native), KMP takes a pragmatic approach — share the code that benefits from sharing (networking, data models, business logic, caching) while keeping native UI for each platform. Therefore, teams get code reuse where it matters most without sacrificing the native user experience that platform-specific APIs provide. This comprehensive guide covers everything from project setup to production deployment, including architecture patterns, shared networking, database integration, testing strategies, and CI/CD configuration.
The fundamental insight behind KMP is that 60-70% of mobile app code is platform-independent — API calls, data transformation, caching, validation, analytics, and business rules work the same regardless of whether the app runs on Android or iOS. Moreover, keeping this logic in a single shared module eliminates the bug duplication problem where the same business rule is implemented differently (and often incorrectly) on each platform. However, KMP is not without trade-offs — it adds build complexity, requires iOS developers to understand Kotlin, and has a learning curve for expect/actual declarations. Consequently, the decision to adopt it should weigh the size of your shared logic against the cost of a more intricate toolchain.
Project Architecture and Module Structure
A well-structured KMP project separates concerns into clear modules. The shared module contains platform-independent business logic, while platform-specific modules handle UI, platform APIs, and dependency injection. Furthermore, using a clean architecture approach within the shared module ensures that platform dependencies are isolated behind interfaces that can be implemented differently on each platform.
// Project structure
// my-app/
// ├── shared/ # KMP shared module
// │ ├── src/commonMain/ # Shared code (Kotlin)
// │ │ ├── data/ # Repositories, data sources
// │ │ ├── domain/ # Use cases, models
// │ │ ├── network/ # Ktor HTTP client
// │ │ └── database/ # SQLDelight schemas
// │ ├── src/androidMain/ # Android-specific implementations
// │ ├── src/iosMain/ # iOS-specific implementations
// │ └── src/commonTest/ # Shared tests
// ├── androidApp/ # Android UI (Jetpack Compose)
// └── iosApp/ # iOS UI (SwiftUI)
// shared/src/commonMain/domain/model/Product.kt
data class Product(
val id: String,
val name: String,
val price: Double,
val category: String,
val imageUrl: String,
val rating: Double,
val reviewCount: Int,
)
// shared/src/commonMain/domain/usecase/GetProductsUseCase.kt
class GetProductsUseCase(
private val repository: ProductRepository,
private val analytics: AnalyticsTracker,
) {
suspend fun execute(
category: String? = null,
sortBy: SortOption = SortOption.POPULAR,
): Result> {
analytics.trackEvent("products_viewed", mapOf("category" to (category ?: "all")))
return repository.getProducts(category, sortBy)
.map { products ->
products.filter { it.price > 0 } // Business rule: no free products
.sortedByDescending {
when (sortBy) {
SortOption.POPULAR -> it.reviewCount.toDouble()
SortOption.RATING -> it.rating
SortOption.PRICE_LOW -> -it.price
SortOption.PRICE_HIGH -> it.price
}
}
}
}
}
// shared/src/commonMain/domain/repository/ProductRepository.kt
interface ProductRepository {
suspend fun getProducts(
category: String? = null,
sortBy: SortOption = SortOption.POPULAR,
): Result>
suspend fun getProductById(id: String): Result
suspend fun searchProducts(query: String): Result>
}
Configuring the Gradle Build for Multiple Targets
Before any of this code compiles, the shared module’s build.gradle.kts must declare every target and wire up the source set hierarchy. In practice, the Kotlin Multiplatform plugin generates an XCFramework for iOS while producing an AAR for Android, and the source sets define which dependencies each platform resolves. Notably, the commonMain source set can only depend on multiplatform-aware libraries, so a dependency that works on Android (like Retrofit) will not resolve there — you reach for Ktor instead.
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("app.cash.sqldelight")
}
kotlin {
androidTarget()
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "Shared"
isStatic = true // static framework links faster in Xcode
}
}
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.12")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.12")
}
}
}
The isStatic = true flag is a small detail with a large impact: a static framework avoids the dynamic-linking overhead at app launch, which iOS teams generally prefer for faster cold starts. Conversely, if multiple frameworks share the same Kotlin runtime, a dynamic framework prevents duplicate symbols — so the right answer depends on how many KMP modules you ship.
Kotlin Multiplatform Production: Shared Networking with Ktor
Ktor is the standard HTTP client for KMP projects, providing a consistent API across platforms with platform-specific engines (OkHttp on Android, Darwin/URLSession on iOS). Furthermore, Ktor integrates seamlessly with kotlinx.serialization for type-safe JSON parsing, eliminating the need for separate serialization libraries on each platform.
// shared/src/commonMain/network/ApiClient.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class ApiClient(engine: HttpClientEngine) {
private val httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = false
isLenient = true
})
}
install(Logging) {
level = LogLevel.HEADERS
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
}
defaultRequest {
url("https://api.myapp.com/v2/")
header("X-Api-Version", "2.0")
}
HttpResponseValidator {
validateResponse { response ->
if (response.status.value >= 400) {
val body = response.body()
throw ApiException(response.status.value, body.message)
}
}
}
}
suspend fun getProducts(
category: String? = null,
page: Int = 1,
): List {
return httpClient.get("products") {
parameter("page", page)
parameter("per_page", 20)
category?.let { parameter("category", it) }
}.body()
}
suspend fun getProduct(id: String): ProductDto {
return httpClient.get("products/$id").body()
}
suspend fun searchProducts(query: String): List {
return httpClient.get("products/search") {
parameter("q", query)
}.body()
}
}
// Platform-specific engine creation
// shared/src/androidMain/network/Engine.kt
actual fun createHttpEngine(): HttpClientEngine = OkHttp.create {
config { retryOnConnectionFailure(true) }
}
// shared/src/iosMain/network/Engine.kt
actual fun createHttpEngine(): HttpClientEngine = Darwin.create {
configureRequest { setTimeoutInterval(15.0) }
}
Shared Database with SQLDelight
SQLDelight generates type-safe Kotlin code from SQL statements, providing compile-time verified database access that works on both Android (SQLite) and iOS (SQLite via native driver). Furthermore, SQLDelight supports migrations, complex queries with joins, and Flow-based reactive queries that automatically update the UI when data changes.
-- shared/src/commonMain/sqldelight/com/myapp/db/Product.sq
CREATE TABLE product (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT NOT NULL,
image_url TEXT NOT NULL,
rating REAL NOT NULL DEFAULT 0.0,
review_count INTEGER NOT NULL DEFAULT 0,
is_favorite INTEGER NOT NULL DEFAULT 0,
last_updated INTEGER NOT NULL
);
CREATE INDEX idx_product_category ON product(category);
selectAll:
SELECT * FROM product
ORDER BY
CASE WHEN :sort = 'popular' THEN review_count END DESC,
CASE WHEN :sort = 'rating' THEN rating END DESC,
CASE WHEN :sort = 'price_low' THEN price END ASC,
CASE WHEN :sort = 'price_high' THEN price END DESC;
selectByCategory:
SELECT * FROM product
WHERE category = :category
ORDER BY review_count DESC;
searchByName:
SELECT * FROM product
WHERE name LIKE '%' || :query || '%'
ORDER BY rating DESC
LIMIT 20;
selectFavorites:
SELECT * FROM product WHERE is_favorite = 1;
toggleFavorite:
UPDATE product SET is_favorite = CASE
WHEN is_favorite = 1 THEN 0 ELSE 1
END WHERE id = :id;
upsert:
INSERT OR REPLACE INTO product(
id, name, price, category, image_url,
rating, review_count, is_favorite, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
The platform driver is the one piece SQLDelight cannot share: Android uses AndroidSqliteDriver while iOS uses NativeSqliteDriver. Inject the driver through your dependency graph rather than constructing it in common code, and your repositories stay fully testable with an in-memory driver during unit tests. Importantly, SQLDelight exposes queries as Coroutines Flow, so a single selectAll().asFlow() call drives reactive updates on both a Jetpack Compose screen and a SwiftUI view without any per-platform glue.
expect/actual: Platform-Specific Implementations
The expect/actual mechanism lets you declare an API in common code and provide platform-specific implementations. Use this sparingly — only for truly platform-specific functionality like file access, biometric authentication, notifications, or platform analytics SDKs. Additionally, prefer interfaces with dependency injection over expect/actual when the implementation involves complex logic.
// shared/src/commonMain/platform/Platform.kt
expect class PlatformContext
expect fun getPlatformName(): String
expect class SecureStorage(context: PlatformContext) {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
fun clear()
}
// shared/src/androidMain/platform/Platform.android.kt
actual typealias PlatformContext = android.content.Context
actual fun getPlatformName(): String = "Android " + android.os.Build.VERSION.SDK_INT
actual class SecureStorage actual constructor(
private val context: PlatformContext
) {
private val prefs = EncryptedSharedPreferences.create(
context, "secure_prefs",
MasterKey.Builder(context).setKeyScheme(
MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
actual fun getString(key: String): String? = prefs.getString(key, null)
actual fun putString(key: String, value: String) =
prefs.edit().putString(key, value).apply()
actual fun remove(key: String) = prefs.edit().remove(key).apply()
actual fun clear() = prefs.edit().clear().apply()
}
// shared/src/iosMain/platform/Platform.ios.kt
actual class PlatformContext
actual fun getPlatformName(): String =
UIDevice.currentDevice.systemName + " " +
UIDevice.currentDevice.systemVersion
actual class SecureStorage actual constructor(context: PlatformContext) {
actual fun getString(key: String): String? {
val query = keychainQuery(key) + mapOf(kSecReturnData to kCFBooleanTrue)
// Keychain implementation...
}
// ... other methods using iOS Keychain
}
A practical rule of thumb: reach for an interface plus constructor injection first, and fall back to expect/actual only when the type itself must differ per platform (as with the typealias PlatformContext = Context above). This keeps the bulk of your code in commonMain, where it is testable, and confines the harder-to-test platform code to thin adapters.
Consuming Shared Code From SwiftUI
On Android the shared module is just another Kotlin dependency, so consumption is effortless. The iOS side deserves attention because the generated Objective-C interop has rough edges. Suspending functions become Swift functions with completion handlers (or async/await with recent Kotlin versions), and Kotlin Flow does not bridge natively — teams typically wrap it with a small helper or a library like SKIE or KMP-NativeCoroutines. The example below shows the common pattern of a thin observable wrapper that a SwiftUI @StateObject can bind to.
// iosApp/ProductListViewModel.swift
import Shared
import Combine
@MainActor
final class ProductListViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var isLoading = false
private let useCase: GetProductsUseCase = DIContainer.shared.getProductsUseCase
func load(category: String? = nil) async {
isLoading = true
defer { isLoading = false }
do {
// Suspend function exposed to Swift as async via KMP-NativeCoroutines
let result = try await useCase.execute(category: category, sortBy: .popular)
self.products = result
} catch {
print("Failed to load products: \(error)")
}
}
}
Because the iOS team writes Swift that calls into Kotlin, the integration boundary is where most friction lives. As a result, successful teams treat the shared module’s public API as a product in its own right — keeping signatures simple, avoiding Kotlin-only constructs like default arguments in public functions, and documenting how each type maps to Swift.
Testing Shared Code
One of KMP’s biggest advantages is testing business logic once in commonTest instead of duplicating tests on each platform. Use kotlin.test for assertions and kotlinx-coroutines-test for testing suspending functions. Furthermore, create fake implementations of platform interfaces for testing, ensuring your shared logic is thoroughly verified without platform dependencies.
// shared/src/commonTest/domain/GetProductsUseCaseTest.kt
class GetProductsUseCaseTest {
private val fakeRepository = FakeProductRepository()
private val fakeAnalytics = FakeAnalyticsTracker()
private val useCase = GetProductsUseCase(fakeRepository, fakeAnalytics)
@Test
fun shouldFilterFreeProducts() = runTest {
fakeRepository.setProducts(listOf(
testProduct(price = 29.99),
testProduct(price = 0.0), // Should be filtered
testProduct(price = 49.99),
))
val result = useCase.execute()
assertTrue(result.isSuccess)
assertEquals(2, result.getOrThrow().size)
assertTrue(result.getOrThrow().all { it.price > 0 })
}
@Test
fun shouldSortByRating() = runTest {
fakeRepository.setProducts(listOf(
testProduct(rating = 3.5),
testProduct(rating = 4.8),
testProduct(rating = 4.2),
))
val result = useCase.execute(sortBy = SortOption.RATING)
val ratings = result.getOrThrow().map { it.rating }
assertEquals(listOf(4.8, 4.2, 3.5), ratings)
}
@Test
fun shouldTrackAnalytics() = runTest {
useCase.execute(category = "electronics")
assertEquals(1, fakeAnalytics.events.size)
assertEquals("products_viewed", fakeAnalytics.events[0].name)
assertEquals("electronics", fakeAnalytics.events[0].params["category"])
}
}
One caveat worth knowing: tests in commonTest run against every target, and the iOS test runner executes on the Kotlin/Native memory model rather than the JVM. Most pure-logic tests behave identically, but anything touching threading or freezing can surface platform-specific differences. Therefore, run the iOS test target in CI rather than trusting that a green JVM run guarantees correctness everywhere.
CI/CD for KMP Projects
KMP projects require CI runners that can build both Android and iOS targets. Use macOS runners for iOS builds (XCFramework compilation requires Xcode) and Linux/macOS for Android builds. Furthermore, cache Gradle and CocoaPods dependencies aggressively — KMP builds are slower than single-platform builds due to the multi-target compilation. A common pattern is to split the pipeline: a fast Linux job that compiles and tests commonMain plus the Android target on every push, and a slower, more expensive macOS job that builds the XCFramework and runs iOS tests only on pull requests targeting the main branch. Benchmarks from teams that publish their build metrics consistently show Kotlin/Native compilation as the slowest stage, which makes the Gradle build cache and Kotlin/Native dependency cache the highest-leverage things to persist between runs.
When NOT to Use KMP
KMP is not the right choice for every project. Avoid it when your Android and iOS apps have fundamentally different business logic, when your team lacks Kotlin experience, or when your iOS team strongly resists adopting Kotlin tooling. Additionally, if your app is primarily UI with minimal shared logic (like a camera app or game), the overhead of KMP setup outweighs the benefits. Furthermore, very small teams (1-2 developers) may find the build complexity not worth the code sharing benefits. There is also an honest organizational trade-off: KMP blurs the line between the Android and iOS teams, and a debugging session that crosses the Kotlin/Swift boundary needs someone fluent in both. In organizations where those teams are strictly siloed, that shared ownership can create more friction than the code reuse saves. As a rule, the value of KMP scales with the proportion of your codebase that is genuine business logic rather than UI — so audit that ratio before committing.
Key Takeaways
Kotlin Multiplatform production development is ready for serious applications in 2026. Share business logic, networking, database access, and validation between Android and iOS while keeping native UI for the best user experience. Start with a small shared module — networking or data models — prove the value, then gradually expand shared code coverage. The teams that succeed with KMP are those that start small, invest in shared testing, and maintain clear boundaries between shared and platform-specific code.
Related Reading:
- Jetpack Compose Advanced Animations
- Kotlin Coroutines Flow Production Guide
- Mobile App Security Best Practices
External Resources:
In conclusion, Kotlin Multiplatform Ios Android 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.