Kotlin Multiplatform Production Guide: Sharing Code Between Android and iOS

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.

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>
}
Kotlin Multiplatform mobile development
KMP shares business logic while keeping native UI — the best of both worlds

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 (?, ?, ?, ?, ?, ?, ?, ?, ?);

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
}
Mobile app development cross-platform
expect/actual declarations enable platform-specific implementations with shared interfaces

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"])
    }
}

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.

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.

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:

External Resources:

Scroll to Top