Why KMP and Why Now
Project Structure: The expect/actual Pattern
// shared/src/commonMain/kotlin/Platform.kt
expect class PlatformContext
expect fun getPlatformName(): String
// shared/src/androidMain/kotlin/Platform.android.kt
actual typealias PlatformContext = android.content.Context
actual fun getPlatformName(): String = "Android"
// shared/src/iosMain/kotlin/Platform.ios.kt
import platform.UIKit.UIDevice
actual class PlatformContext
actual fun getPlatformName(): String =
UIDevice.currentDevice.systemName()Sharing Networking with Ktor
// 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/src/commonMain/kotlin/api/HttpClientFactory.kt
expect fun createPlatformHttpClient(): HttpClient
// shared/src/androidMain/kotlin/api/HttpClientFactory.android.kt
actual fun createPlatformHttpClient(): HttpClient =
HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.HEADERS
}
}Shared Data Layer with SQLDelight
-- shared/src/commonMain/sqldelight/com/app/db/Task.sq
CREATE TABLE TaskEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
isCompleted INTEGER AS Boolean 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
// shared/src/commonTest/kotlin/TaskRepositoryTest.kt
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)
// Verify it was cached in the database
assertEquals(1, fakeDb.getAllTasks().size)
}
@Test
fun createTask_validatesTitle() = runTest {
val result = repository.createTask(title = "")
assertTrue(result.isFailure)
assertEquals(
"Title cannot be empty",
result.exceptionOrNull()?.message
)
}
}Incremental Migration Strategy
Key Takeaways
- 1KMP shares business logic and data layers, not UI -- your Android and iOS apps stay fully native.
- 2The expect/actual pattern provides clean platform abstraction without runtime overhead.
- 3Ktor and SQLDelight are the standard KMP libraries for networking and local persistence.
- 4Write tests once in commonTest and run them on all platforms for maximum coverage.
- 5Migrate incrementally: data models first, then business logic, then repositories, then networking.
- 6Keep ViewModels platform-specific -- they bridge shared logic to platform-native UI lifecycles.
Frequently Asked
Does KMP increase build complexity?
Initially yes, but the complexity is manageable with proper setup. Benefits outweigh complexity for teams maintaining both Android and iOS apps.
Can I migrate incrementally?
Yes. Start with data models, then networking, then business logic. Each layer can be migrated independently. Keep Android app fully functional throughout migration.
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.