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

Understanding KMM Architecture Infographic

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

Shared Module Patterns Infographic 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:

  1. Start with the domain layer - Models, use cases, and business logic are ideal for sharing
  2. Use expect/actual sparingly - Design abstractions that minimize platform-specific code
  3. Test shared code thoroughly - Common tests run on all platforms
  4. Keep UI platform-native - SwiftUI and Compose provide the best user experience
  5. 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.