Introduction
Kotlin Multiplatform Mobile (KMM) takes a unique approach to cross-platform development: share business logic while keeping native UI. This preserves the platform-specific user experience that users expect while eliminating the duplication of networking, data processing, and business rules.
This guide covers KMM from project setup through production deployment, with practical patterns for building maintainable shared codebases.
Understanding KMM Architecture

The Shared Code Model
Unlike Flutter or React Native which share UI, KMM shares only the logic layer:
┌─────────────────────────────────────────────────────────┐
│ Android App │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Jetpack Compose UI │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Shared Kotlin Module │ │
│ │ • ViewModels / Repositories │ │
│ │ • Business Logic │ │
│ │ • Data Models │ │
│ │ • Networking │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ iOS App │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SwiftUI │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Shared Kotlin Module │ │
│ │ (Compiled to Native Framework) │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────────────────────────────────────┘
What to Share
Ideal for Sharing:
- Data models and DTOs
- API clients and networking
- Business logic and validation
- Data transformation and mapping
- Local storage (SQLDelight, settings)
- Analytics and logging
Keep Platform-Specific:
- UI components and layouts
- Navigation
- Platform integrations (camera, sensors)
- OS-specific APIs
- Performance-critical code
Proje
ct Setup
Gradle Configuration
// build.gradle.kts (root)
plugins {
kotlin("multiplatform") version "1.9.22" apply false
kotlin("plugin.serialization") version "1.9.22" apply false
id("com.android.application") version "8.2.0" apply false
id("com.android.library") version "8.2.0" apply false
}
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
id("app.cash.sqldelight") version "2.0.1"
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("app.cash.sqldelight:runtime:2.0.1")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-android:2.3.7")
implementation("app.cash.sqldelight:android-driver:2.0.1")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
implementation("app.cash.sqldelight:native-driver:2.0.1")
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
}
}
android {
namespace = "com.example.shared"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
Project Structure
project/
├── androidApp/
│ ├── src/main/kotlin/
│ │ └── com.example.app/
│ │ ├── MainActivity.kt
│ │ └── ui/
│ └── build.gradle.kts
├── iosApp/
│ ├── iosApp/
│ │ ├── ContentView.swift
│ │ └── iOSApp.swift
│ └── iosApp.xcodeproj
├── shared/
│ ├── src/
│ │ ├── commonMain/kotlin/
│ │ │ └── com.example.shared/
│ │ │ ├── data/
│ │ │ ├── domain/
│ │ │ └── platform/
│ │ ├── androidMain/kotlin/
│ │ └── iosMain/kotlin/
│ └── build.gradle.kts
└── build.gradle.kts
Shared Module
Patterns
Data Models
// commonMain/kotlin/com.example.shared/data/models/Product.kt
@Serializable
data class Product(
val id: String,
val name: String,
val description: String,
val price: Double,
val currency: String,
val imageUrls: List<String>,
val category: ProductCategory,
val availability: Availability
)
@Serializable
enum class ProductCategory {
ELECTRONICS,
CLOTHING,
HOME,
SPORTS,
OTHER
}
@Serializable
data class Availability(
val inStock: Boolean,
val quantity: Int,
val estimatedDeliveryDays: Int?
)
API Client
// commonMain/kotlin/com.example.shared/data/api/ApiClient.kt
class ApiClient(
private val httpClient: HttpClient,
private val baseUrl: String
) {
suspend fun getProducts(
category: ProductCategory? = null,
page: Int = 1,
limit: Int = 20
): Result<ProductListResponse> {
return runCatching {
httpClient.get("$baseUrl/products") {
parameter("page", page)
parameter("limit", limit)
category?.let { parameter("category", it.name) }
}.body<ProductListResponse>()
}
}
suspend fun getProduct(id: String): Result<Product> {
return runCatching {
httpClient.get("$baseUrl/products/$id").body<Product>()
}
}
suspend fun searchProducts(query: String): Result<List<Product>> {
return runCatching {
httpClient.get("$baseUrl/products/search") {
parameter("q", query)
}.body<ProductSearchResponse>().products
}
}
}
// Platform-specific HTTP client creation
expect fun createHttpClient(): HttpClient
// commonMain/kotlin/com.example.shared/data/api/HttpClientFactory.kt
fun createDefaultHttpClient(): HttpClient {
return createHttpClient().config {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = false
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}
}
// androidMain/kotlin/.../HttpClient.android.kt
actual fun createHttpClient(): HttpClient = HttpClient(Android)
// iosMain/kotlin/.../HttpClient.ios.kt
actual fun createHttpClient(): HttpClient = HttpClient(Darwin)
Repository Pattern
// commonMain/kotlin/com.example.shared/domain/repository/ProductRepository.kt
class ProductRepository(
private val apiClient: ApiClient,
private val productCache: ProductCache,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private val _products = MutableStateFlow<List<Product>>(emptyList())
val products: StateFlow<List<Product>> = _products.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
suspend fun loadProducts(
category: ProductCategory? = null,
forceRefresh: Boolean = false
): Result<List<Product>> = withContext(dispatcher) {
_isLoading.value = true
try {
// Check cache first
if (!forceRefresh) {
val cached = productCache.getProducts(category)
if (cached.isNotEmpty()) {
_products.value = cached
return@withContext Result.success(cached)
}
}
// Fetch from API
val result = apiClient.getProducts(category = category)
result.onSuccess { response ->
val products = response.products
productCache.saveProducts(products)
_products.value = products
}
result.map { it.products }
} finally {
_isLoading.value = false
}
}
suspend fun getProductById(id: String): Result<Product> = withContext(dispatcher) {
// Check cache
productCache.getProduct(id)?.let {
return@withContext Result.success(it)
}
// Fetch from API
apiClient.getProduct(id).onSuccess { product ->
productCache.saveProduct(product)
}
}
suspend fun searchProducts(query: String): Result<List<Product>> = withContext(dispatcher) {
apiClient.searchProducts(query)
}
}
Use Cases
// commonMain/kotlin/com.example.shared/domain/usecase/GetProductsUseCase.kt
class GetProductsUseCase(
private val productRepository: ProductRepository
) {
suspend operator fun invoke(
category: ProductCategory? = null,
forceRefresh: Boolean = false
): Result<List<Product>> {
return productRepository.loadProducts(
category = category,
forceRefresh = forceRefresh
)
}
}
// commonMain/kotlin/com.example.shared/domain/usecase/AddToCartUseCase.kt
class AddToCartUseCase(
private val cartRepository: CartRepository,
private val productRepository: ProductRepository
) {
suspend operator fun invoke(
productId: String,
quantity: Int = 1
): Result<Cart> {
// Validate product exists and is available
val productResult = productRepository.getProductById(productId)
return productResult.fold(
onSuccess = { product ->
if (!product.availability.inStock) {
Result.failure(ProductNotAvailableException(productId))
} else if (quantity > product.availability.quantity) {
Result.failure(InsufficientStockException(
productId,
requested = quantity,
available = product.availability.quantity
))
} else {
cartRepository.addItem(product, quantity)
}
},
onFailure = { Result.failure(it) }
)
}
}
Platform Integration with Expect/Actual
Platform-Specific Implementations
// commonMain/kotlin/.../platform/Platform.kt
expect class Platform() {
val name: String
val version: String
val deviceId: String
}
// androidMain/kotlin/.../platform/Platform.android.kt
actual class Platform actual constructor() {
actual val name: String = "Android"
actual val version: String = "${Build.VERSION.SDK_INT}"
actual val deviceId: String = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
)
}
// iosMain/kotlin/.../platform/Platform.ios.kt
actual class Platform actual constructor() {
actual val name: String = UIDevice.currentDevice.systemName()
actual val version: String = UIDevice.currentDevice.systemVersion
actual val deviceId: String = UIDevice.currentDevice.identifierForVendor?.UUIDString ?: ""
}
Local Storage with SQLDelight
// shared/src/commonMain/sqldelight/com/example/shared/cache/Product.sq
CREATE TABLE ProductEntity (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL,
category TEXT NOT NULL,
inStock INTEGER AS Boolean NOT NULL DEFAULT 1,
quantity INTEGER NOT NULL DEFAULT 0,
lastUpdated INTEGER NOT NULL
);
selectAll:
SELECT * FROM ProductEntity
ORDER BY lastUpdated DESC;
selectByCategory:
SELECT * FROM ProductEntity
WHERE category = ?
ORDER BY name ASC;
selectById:
SELECT * FROM ProductEntity
WHERE id = ?;
insertOrReplace:
INSERT OR REPLACE INTO ProductEntity(id, name, description, price, currency, category, inStock, quantity, lastUpdated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
deleteById:
DELETE FROM ProductEntity WHERE id = ?;
deleteAll:
DELETE FROM ProductEntity;
// commonMain/kotlin/.../cache/ProductCache.kt
class ProductCache(
databaseDriverFactory: DatabaseDriverFactory
) {
private val database = AppDatabase(databaseDriverFactory.createDriver())
private val queries = database.productEntityQueries
fun getProducts(category: ProductCategory? = null): List<Product> {
val entities = if (category != null) {
queries.selectByCategory(category.name).executeAsList()
} else {
queries.selectAll().executeAsList()
}
return entities.map { it.toProduct() }
}
fun getProduct(id: String): Product? {
return queries.selectById(id).executeAsOneOrNull()?.toProduct()
}
fun saveProducts(products: List<Product>) {
database.transaction {
products.forEach { product ->
queries.insertOrReplace(
id = product.id,
name = product.name,
description = product.description,
price = product.price,
currency = product.currency,
category = product.category.name,
inStock = product.availability.inStock,
quantity = product.availability.quantity.toLong(),
lastUpdated = Clock.System.now().toEpochMilliseconds()
)
}
}
}
fun saveProduct(product: Product) {
queries.insertOrReplace(
id = product.id,
name = product.name,
description = product.description,
price = product.price,
currency = product.currency,
category = product.category.name,
inStock = product.availability.inStock,
quantity = product.availability.quantity.toLong(),
lastUpdated = Clock.System.now().toEpochMilliseconds()
)
}
}
// Database driver factory
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
}
// iosMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}
Consuming Shared Code
Android Integration
// androidApp/src/main/kotlin/.../viewmodel/ProductListViewModel.kt
class ProductListViewModel(
private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductListUiState>(ProductListUiState.Loading)
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
fun loadProducts(category: ProductCategory? = null) {
viewModelScope.launch {
_uiState.value = ProductListUiState.Loading
getProductsUseCase(category).fold(
onSuccess = { products ->
_uiState.value = ProductListUiState.Success(products)
},
onFailure = { error ->
_uiState.value = ProductListUiState.Error(error.message ?: "Unknown error")
}
)
}
}
fun refresh() {
viewModelScope.launch {
getProductsUseCase(forceRefresh = true)
}
}
}
// androidApp/src/main/kotlin/.../ui/ProductListScreen.kt
@Composable
fun ProductListScreen(
viewModel: ProductListViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
is ProductListUiState.Loading -> LoadingIndicator()
is ProductListUiState.Success -> ProductList(products = state.products)
is ProductListUiState.Error -> ErrorMessage(message = state.message)
}
}
iOS Integration
// iosApp/iosApp/ViewModels/ProductListViewModel.swift
import Shared
@MainActor
class ProductListViewModel: ObservableObject {
private let getProductsUseCase: GetProductsUseCase
@Published var products: [Product] = []
@Published var isLoading = false
@Published var error: String?
init(getProductsUseCase: GetProductsUseCase) {
self.getProductsUseCase = getProductsUseCase
}
func loadProducts(category: ProductCategory? = nil) async {
isLoading = true
error = nil
do {
let result = try await getProductsUseCase.invoke(
category: category,
forceRefresh: false
)
if let products = result.getOrNull() {
self.products = products
} else if let throwable = result.exceptionOrNull() {
self.error = throwable.message ?? "Unknown error"
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// iosApp/iosApp/Views/ProductListView.swift
struct ProductListView: View {
@StateObject private var viewModel: ProductListViewModel
init() {
// Dependency injection
let apiClient = ApiClient(
httpClient: HttpClientFactoryKt.createDefaultHttpClient(),
baseUrl: "https://api.example.com"
)
let cache = ProductCache(
databaseDriverFactory: DatabaseDriverFactory()
)
let repository = ProductRepository(
apiClient: apiClient,
productCache: cache,
dispatcher: Dispatchers.shared.Default
)
let useCase = GetProductsUseCase(productRepository: repository)
_viewModel = StateObject(wrappedValue: ProductListViewModel(getProductsUseCase: useCase))
}
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
Text(error)
.foregroundColor(.red)
} else {
List(viewModel.products, id: \.id) { product in
ProductRow(product: product)
}
}
}
.task {
await viewModel.loadProducts()
}
}
}
Testing Shared Code
Common Tests
// commonTest/kotlin/.../repository/ProductRepositoryTest.kt
class ProductRepositoryTest {
private lateinit var repository: ProductRepository
private lateinit var mockApiClient: MockApiClient
private lateinit var mockCache: MockProductCache
@BeforeTest
fun setup() {
mockApiClient = MockApiClient()
mockCache = MockProductCache()
repository = ProductRepository(
apiClient = mockApiClient,
productCache = mockCache,
dispatcher = UnconfinedTestDispatcher()
)
}
@Test
fun `loadProducts returns cached data when available`() = runTest {
val cachedProducts = listOf(
createTestProduct("1"),
createTestProduct("2")
)
mockCache.setProducts(cachedProducts)
val result = repository.loadProducts()
assertTrue(result.isSuccess)
assertEquals(cachedProducts, result.getOrNull())
assertEquals(0, mockApiClient.getProductsCallCount)
}
@Test
fun `loadProducts fetches from API when cache is empty`() = runTest {
val apiProducts = listOf(createTestProduct("1"))
mockApiClient.setProductsResponse(ProductListResponse(apiProducts))
val result = repository.loadProducts()
assertTrue(result.isSuccess)
assertEquals(apiProducts, result.getOrNull())
assertEquals(1, mockApiClient.getProductsCallCount)
assertEquals(apiProducts, mockCache.savedProducts)
}
@Test
fun `loadProducts with forceRefresh bypasses cache`() = runTest {
val cachedProducts = listOf(createTestProduct("old"))
val apiProducts = listOf(createTestProduct("new"))
mockCache.setProducts(cachedProducts)
mockApiClient.setProductsResponse(ProductListResponse(apiProducts))
val result = repository.loadProducts(forceRefresh = true)
assertTrue(result.isSuccess)
assertEquals(apiProducts, result.getOrNull())
}
}
Production Considerations
Framework Distribution
For iOS, generate the framework:
# Debug build for simulator
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
# Release build for device
./gradlew :shared:linkReleaseFrameworkIosArm64
# XCFramework for distribution
./gradlew :shared:assembleXCFramework
Performance Optimization
// Freeze objects for iOS concurrency (pre-Kotlin 1.7.20)
// Now handled automatically with the new memory manager
// Use background dispatchers for heavy operations
class HeavyProcessingUseCase(
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun process(data: LargeData): Result<ProcessedData> =
withContext(dispatcher) {
// Heavy processing here
}
}
// Minimize crossing the Kotlin/Swift boundary in hot paths
// Batch operations when possible
class BatchOperations {
// Instead of many small calls
suspend fun processItems(items: List<Item>): List<Result> {
return items.map { processItem(it) }
}
}
Error Handling Across Platforms
// Sealed class for typed errors
sealed class AppError : Exception() {
data class NetworkError(override val message: String) : AppError()
data class ValidationError(val field: String, override val message: String) : AppError()
data class NotFoundError(val resourceId: String) : AppError()
object Unauthorized : AppError()
}
// iOS-friendly error handling
fun Result<*>.toSwiftResult(): SwiftResult {
return fold(
onSuccess = { SwiftResult.Success(it) },
onFailure = { error ->
val errorInfo = when (error) {
is AppError.NetworkError -> ErrorInfo("network", error.message)
is AppError.ValidationError -> ErrorInfo("validation", error.message)
is AppError.NotFoundError -> ErrorInfo("not_found", error.resourceId)
is AppError.Unauthorized -> ErrorInfo("unauthorized", "Session expired")
else -> ErrorInfo("unknown", error.message ?: "Unknown error")
}
SwiftResult.Failure(errorInfo)
}
)
}
Conclusion
Kotlin Multiplatform Mobile offers a pragmatic approach to cross-platform development. By sharing business logic while keeping native UI, you get the best of both worlds: code reuse where it matters most and native user experience where users notice.
Key takeaways:
- Start with the domain layer - Models, use cases, and business logic are ideal for sharing
- Use expect/actual sparingly - Design abstractions that minimize platform-specific code
- Test shared code thoroughly - Common tests run on all platforms
- Keep UI platform-native - SwiftUI and Compose provide the best user experience
- Plan for iOS integration - Swift interop requires thoughtful API design
KMM is production-ready and powers major apps at companies like Netflix, Philips, and Cash App. For teams with Kotlin expertise building for both platforms, it’s an excellent choice that reduces duplication without sacrificing quality.