SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:23:10
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
All Articles
Kotlin · Updated 12 MIN

Kotlin Multiplatform for Android Developers: Sharing Code Without Losing Native Quality

Move business logic, networking, and data layers to a shared Kotlin module while keeping native Android and iOS UIs. KMP patterns for teams shipping Android.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Is Kotlin Multiplatform ready for a production Android team?

The status question has a documented answer from both sides. JetBrains lists Kotlin Multiplatform as Stable since Kotlin 1.9.20 in its stability table. Android's own page says "Kotlin Multiplatform (KMP) is officially supported by Google for sharing business logic between Android and iOS" and "Kotlin Multiplatform is stable and production-ready" (Android). Read the first sentence carefully, because it sets the scope. The supported use is sharing business logic. KMP does not ask you to replace your UI: the Android app keeps Jetpack Compose and the iOS app keeps its native toolkit. Sharing UI is a separate, optional step, and the same page says so: "With JetBrains' Compose Multiplatform (CMP), developers can also share UI across platforms." Stable does not mean every corner is. Two parts you will meet early are not: the Android Studio plugin for KMP is listed as Beta, and so are expected and actual classes, which get their own section below. What changed since this article was first written is how much of Jetpack came along. Android now documents KMP setup for ViewModel, Room and DataStore. The earlier version told you to keep ViewModels platform-specific and to reach for SQLDelight. Both were reasonable then and are no longer the only route.

Project Structure: source sets and the Android target

A shared module is organised into source sets. "Kotlin Multiplatform provides a number of predefined source sets. One of them is commonMain, which is present in all multiplatform projects and compiles to all declared targets" (Kotlin). Code in androidMain and iosMain compiles only for its platform and can see commonMain. The reverse is not true. The Android side of the build changed with Android Gradle plugin 9. "The com.android.kotlin.multiplatform.library Gradle plugin is the officially supported tool for adding an Android target to a Kotlin Multiplatform (KMP) library module" (Android). The older setup, com.android.library next to the multiplatform plugin, depends on APIs that page describes as deprecated and opt-in from AGP 9.0. If your shared module was created from an older template, this is the first thing to migrate. The surrounding AGP 9 changes are in the version catalog guide. On iOS, "you generate an iOS framework from the shared module and then add it as a dependency to the iOS project" (Kotlin). The documentation describes direct integration through an Xcode build script, Swift Package Manager and CocoaPods, and says to take the CocoaPods route when the Kotlin project itself has Pod dependencies.
kotlin
// shared/build.gradle.kts
plugins {
    id("org.jetbrains.kotlin.multiplatform")
    id("com.android.kotlin.multiplatform.library")
}

kotlin {
    // The Android target is configured inside the kotlin block
    android {
        namespace = "com.myapp.shared"
        compileSdk = 36
        minSdk = 26
    }

    iosArm64() // 64-bit iPhones

    sourceSets {
        commonMain {
            dependencies {
                // Shared dependencies
            }
        }
        androidMain {
            dependencies {
                // Android-only dependencies
            }
        }
    }
}

expect/actual: use it less than the tutorials do

The mechanism for platform differences is expected and actual declarations: declare something in common code, implement it per platform. The earlier version of this article opened with an expect class and an actual typealias to Android's Context. The documentation steers you away from exactly that. First, the status: "Expected and actual classes are in Beta. They are almost stable, but migration steps may be required in the future." The compiler warns when you use them. Second, the advice: "using classes in simple cases where interfaces would be sufficient is not recommended." The reason given is practical: "With interfaces, you don't limit your design to one implementation per target platform. Also, it's much easier to substitute a fake implementation in tests or provide multiple implementations on a single platform." And the general rule: "As a general rule, rely on standard language constructs wherever possible instead of using expected and actual declarations." So the default pattern is an ordinary interface in commonMain and an expected factory function, which is stable. Each platform returns its own implementation. Your tests pass in a fake and never touch the mechanism.
kotlin
// shared/src/commonMain/kotlin/Platform.kt
interface Platform {
    val name: String
}

// An expected FUNCTION, not an expected class
expect fun getPlatform(): Platform

// shared/src/androidMain/kotlin/Platform.android.kt
private class AndroidPlatform : Platform {
    override val name: String = "Android"
}

actual fun getPlatform(): Platform = AndroidPlatform()

// shared/src/iosMain/kotlin/Platform.ios.kt
private class IosPlatform : Platform {
    override val name: String = "iOS"
}

actual fun getPlatform(): Platform = IosPlatform()

// shared/src/commonTest/kotlin/FakePlatform.kt
class FakePlatform(override val name: String = "Test") : Platform

Sharing Networking with Ktor

Ktor is the HTTP client built for this, and the current version, 3.6.0, removes most of the platform ceremony the old sample needed. That sample declared an expected factory function and built an OkHttp client in androidMain. The documentation now says: "For most Kotlin Multiplatform projects, use the ktor-client-engine-defaults artifact. It provides a curated client engine for each target platform, so you don't need to declare separate engine dependencies in platform-specific source sets" (Ktor). With that one dependency in commonMain, the client is created in common code with no engine argument: "If you omit the engine argument, the client chooses an engine automatically from the engine dependencies available". JSON goes through kotlinx.serialization, which is multiplatform by design, so the response models you already have can move to commonMain unchanged. The serialization guide covers the model rules. Everything the client does is a suspend function, and the coroutines guide covers cancellation and scopes.
kotlin
// shared/build.gradle.kts
kotlin {
    sourceSets {
        commonMain {
            dependencies {
                api("io.ktor:ktor-client-engine-defaults:3.6.0")
                implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.0")
            }
        }
    }
}

// shared/src/commonMain/kotlin/api/HttpClientFactory.kt
// No expect/actual: the engine is chosen per platform automatically
fun createHttpClient(): HttpClient = HttpClient {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

// shared/src/commonMain/kotlin/api/TaskApi.kt
class TaskApi(private val client: HttpClient) {

    suspend fun getTasks(): List<TaskDto> =
        client.get("https://api.example.com/tasks").body()

    suspend fun createTask(request: CreateTaskRequest): TaskDto =
        client.post("https://api.example.com/tasks") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.body()
}

Shared Data Layer: Room or SQLDelight

There are two credible choices now, and the earlier version of this article presented only one. Room. If your Android app already uses Room, the shortest path is the one Android documents: Room in common code, with the database built from a shared function and a driver set on the builder. The guide recommends the bundled driver, describing BundledSQLiteDriver as "the recommended driver that includes SQLite compiled from source" (Android). Your entities, DAOs and migrations carry over, and the Room migrations guide covers the Room 3 API they now use. SQLDelight. If you would rather start from SQL, "SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time" (SQLDelight). You write .sq files and the library generates the query functions. The current release is 2.4.1. The choice is about where your team's knowledge is. A team with years of Room entities should not rewrite them as SQL files to share them. A team starting clean, or one that wants SQL to be the source of truth, has a good reason to pick SQLDelight.
sql
-- shared/src/commonMain/sqldelight/com/myapp/db/Task.sq
CREATE TABLE TaskEntity (
    id TEXT NOT NULL PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    isCompleted INTEGER NOT NULL DEFAULT 0,
    createdAt INTEGER NOT NULL,
    updatedAt INTEGER NOT NULL
);

selectAll:
SELECT * FROM TaskEntity
ORDER BY createdAt DESC;

insertOrReplace:
INSERT OR REPLACE INTO TaskEntity(
    id, title, description, isCompleted, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?);

markCompleted:
UPDATE TaskEntity SET isCompleted = 1,
    updatedAt = ? WHERE id = ?;

deleteById:
DELETE FROM TaskEntity WHERE id = ?;

Testing Shared Code

Tests follow the same source set structure. "commonTest is a counterpart for commonMain and compiles to all of the declared targets, allowing you to write common tests" (Kotlin). The framework is kotlin.test, which "comes with the @kotlin.Test annotation and various assertion methods like assertEquals and assertTrue". The earlier version said the framework is translated into each platform's own test framework. The documentation says something narrower: JUnit and XCTest are platform-specific dependencies you can add for platform-specific tests, in their own source sets. Common tests need only kotlin.test, and each target has its own test task. Keep common tests free of platform types. Depend on interfaces, pass in fakes, and the suite runs on every target you declared. That is the payoff of the interface-first rule from the expect/actual section.
kotlin
// shared/src/commonTest/kotlin/TaskRepositoryTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class TaskRepositoryTest {

    private val fakeApi = FakeTaskApi()
    private val fakeDb = FakeTaskDatabase()
    private val repository = TaskRepository(fakeApi, fakeDb)

    @Test
    fun fetchTasks_cachesLocally() = runTest {
        fakeApi.respondWith(
            listOf(TaskDto("1", "Write tests", false))
        )

        val tasks = repository.getTasks()

        assertEquals(1, tasks.size)
        assertEquals("Write tests", tasks.first().title)
        assertEquals(1, fakeDb.getAllTasks().size)
    }

    @Test
    fun createTask_rejectsAnEmptyTitle() = runTest {
        val result = repository.createTask(title = "")

        assertTrue(result.isFailure)
    }
}

Incremental Migration Strategy

Nothing here has to happen at once, and an Android app can stay shippable at every step. Order the work by risk. Phase 1: models. Move API response models and plain data classes to commonMain. No behaviour changes, and it proves the build, including the new Android KMP library plugin. Phase 2: logic. Move validation, formatting and calculation code. It has no platform dependencies and is the easiest code to cover with common tests. Phase 3: data. Move repositories, with Room or SQLDelight behind them. Put anything platform-specific behind an interface. Phase 4: networking. Replace Retrofit with Ktor in the shared module. This touches the most code, so do it last, when the team has three phases of practice. Then decide about ViewModels. Android documents a KMP setup for ViewModel, so sharing them is possible. It is not required, and a team can stop after phase 4 with platform ViewModels consuming shared repositories. That is a judgment about how much presentation logic the two apps really have in common, and it is yours to make.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Kotlin Multiplatform has been Stable since Kotlin 1.9.20, and Google officially supports it for sharing business logic.
  2. 2KMP shares logic, not UI. Compose Multiplatform is the optional, separate step for shared UI.
  3. 3On AGP 9, add the Android target with the com.android.kotlin.multiplatform.library plugin, not com.android.library.
  4. 4Expected and actual classes are Beta, and the docs recommend interfaces with an expected factory function for simple cases.
  5. 5Ktor chooses an engine per platform through ktor-client-engine-defaults. Room and SQLDelight both work for shared storage.
  6. 6Migrate in phases: models, logic, data, then networking. Sharing ViewModels is possible and optional.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Does KMP increase build complexity?

Yes, and it is worth knowing where. The shared module gains source sets per platform, an iOS framework to produce, and on Android Gradle plugin 9 a different plugin for the Android target. The cost is concentrated in setup. Moving data models first keeps that cost separate from any change in behaviour.

Can I migrate to Kotlin Multiplatform incrementally?

Yes. Start with data models, then validation and other pure logic, then repositories, and replace the networking layer last. Each phase can ship without the next, and the Android app keeps working throughout. Sharing ViewModels is an optional final step that Android documents and does not require.

Should I use expect and actual classes for platform code?

Rarely. Expected and actual classes are in Beta, and the Kotlin documentation says that using them where an interface would be sufficient is not recommended. Declare an interface in common code and an expected factory function that returns it. That form is stable and lets tests substitute a fake implementation.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA