Is Kotlin Multiplatform ready for a production Android team?
Project Structure: source sets and the Android target
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.// 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
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.// 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") : PlatformSharing Networking with Ktor
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.// 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
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.-- 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
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.// 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
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.Key Takeaways
- 1Kotlin Multiplatform has been Stable since Kotlin 1.9.20, and Google officially supports it for sharing business logic.
- 2KMP shares logic, not UI. Compose Multiplatform is the optional, separate step for shared UI.
- 3On AGP 9, add the Android target with the com.android.kotlin.multiplatform.library plugin, not com.android.library.
- 4Expected and actual classes are Beta, and the docs recommend interfaces with an expected factory function for simple cases.
- 5Ktor chooses an engine per platform through ktor-client-engine-defaults. Room and SQLDelight both work for shared storage.
- 6Migrate in phases: models, logic, data, then networking. Sharing ViewModels is possible and optional.
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.
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.