You have an existing Android app. It works. Users like it. Now the team wants an iOS version. Rewriting from scratch in Swift takes months. Kotlin Multiplatform lets you share the business logic and add iOS on top, without rewriting everything.

In this final tutorial, we cover how to migrate an existing Android app to KMP. This is not a “rewrite from scratch” approach. It is a gradual migration — move one layer at a time, keep the Android app working at every step, and add iOS when the shared module is ready.

Before You Start: Assessment

Not every piece of code should be shared. Before you touch any code, make a list of what can move to commonMain and what should stay in Android.

What to Share

These are good candidates for the shared module:

  • Domain models — data classes, enums, sealed classes
  • Business logic — validation, calculations, formatting
  • Networking — API clients (Retrofit to Ktor)
  • Database — local storage (Room to SQLDelight)
  • Preferences — settings and flags (SharedPreferences to DataStore KMP)
  • ViewModels — state management and use cases
  • Utilities — date formatting, string helpers, converters

What to Keep Native

These should stay in platform-specific code:

  • UI — Compose stays on Android, SwiftUI on iOS
  • Platform SDKs — Camera, GPS, Bluetooth, biometrics, NFC
  • Third-party Android SDKs — Firebase, Google Play Services, Crashlytics
  • Android-specific APIs — WorkManager, NotificationManager, ContentProvider
  • Deep linking — each platform has its own deep link handling

The Decision Rule

Ask yourself: “Does this code depend on Android APIs?”

  • No — move it to commonMain
  • Yes, but I can abstract it — use expect/actual
  • Yes, and it is deeply tied to Android — keep it in androidMain

Step 1: Create the Shared Module

Add a shared module to your existing project. This is where all the shared code will live.

Update settings.gradle.kts

// settings.gradle.kts
include(":app")      // Your existing Android app
include(":shared")   // New shared module

Create shared/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    androidTarget {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
        }
        androidMain.dependencies {
            // Platform-specific dependencies
        }
        iosMain.dependencies {
            // Platform-specific dependencies
        }
    }
}

android {
    namespace = "com.yourapp.shared"
    compileSdk = 36
    defaultConfig { minSdk = 24 }
}

Add the Shared Module as a Dependency

In your existing Android app’s build.gradle.kts:

dependencies {
    implementation(project(":shared"))
    // ... existing dependencies
}

Now your Android app can use code from the shared module. The shared module is empty at this point. We will move code into it one piece at a time.

Step 2: Move Domain Models

Start with the simplest, lowest-risk migration: domain models. These are usually plain data classes with no Android dependencies.

Before (Android-only)

// app/src/main/java/com/yourapp/model/User.kt
data class User(
    val id: Long,
    val name: String,
    val email: String,
    val createdAt: Long
)

After (Shared)

// shared/src/commonMain/kotlin/com/yourapp/model/User.kt
import kotlinx.serialization.Serializable

@Serializable
data class User(
    val id: Long,
    val name: String,
    val email: String,
    val createdAt: Long
)

Move the file from app/ to shared/src/commonMain/. Update the package if needed. Add @Serializable if you plan to use the model for networking or database later.

Your Android app still compiles because it depends on shared. The import path stays the same (or you update it). This is a safe change — nothing breaks.

How Many Models to Move

Move all domain models at once. They usually have no dependencies on Android APIs, so this is a fast, low-risk step. If a model uses an Android-specific type (like android.os.Parcelable), leave it in the Android module for now and create a shared version without the Android annotation.

Step 3: Move Networking to Ktor

Most Android apps use Retrofit for networking. KMP does not support Retrofit because it depends on OkHttp (JVM-only). The replacement is Ktor Client.

Retrofit vs Ktor

FeatureRetrofitKtor Client
PlatformJVM onlyAndroid, iOS, Desktop, Web
Interface-basedYesNo (function-based)
JSON parsingGson/Moshikotlinx-serialization
InterceptorsOkHttp interceptorsKtor plugins
CoroutinesWith adapterBuilt-in

Before (Retrofit)

// app/.../api/UserApi.kt
interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Long): UserDto

    @GET("users")
    suspend fun getUsers(): List<UserDto>
}

After (Ktor)

// shared/src/commonMain/.../api/UserApiClient.kt
class UserApiClient(private val httpClient: HttpClient) {
    private val baseUrl = "https://api.example.com"

    suspend fun getUser(id: Long): UserDto =
        httpClient.get("$baseUrl/users/$id").body()

    suspend fun getUsers(): List<UserDto> =
        httpClient.get("$baseUrl/users").body()
}

Migration Strategy

  1. Create the Ktor client in the shared module
  2. Add the same endpoints as your Retrofit interface
  3. Update the Android app to use the new Ktor client
  4. Remove the Retrofit dependency
  5. Run all tests to make sure nothing breaks

Do not try to migrate all endpoints at once. Start with one or two endpoints, verify they work, then migrate the rest.

HttpClient Configuration

Set up the HttpClient in a Koin module:

// shared/src/commonMain/.../di/NetworkModule.kt
val networkModule = module {
    single {
        HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true
                    isLenient = true
                })
            }
            install(HttpTimeout) {
                requestTimeoutMillis = 30_000
            }
        }
    }
    singleOf(::UserApiClient)
}

Platform-specific engines are added in androidMain and iosMain:

// androidMain — uses OkHttp under the hood
// iosMain — uses URLSession under the hood
// No explicit engine config needed — Ktor picks the right one

Step 4: Move Database to SQLDelight

If your app uses Room, you have two options: (1) use Room’s KMP support (added in 2024, now stable for Android + iOS), or (2) migrate to SQLDelight which has been KMP-native from the start. SQLDelight is the more established choice for KMP projects.

Room vs SQLDelight

FeatureRoomSQLDelight
PlatformAndroid onlyAndroid, iOS, Desktop, Web
ApproachAnnotated Kotlin classesSQL-first (.sq files)
QueriesDAO annotationsWritten in SQL
MigrationsJava/Kotlin migration classes.sqm migration files
Flow supportYesYes (with coroutines extension)

Before (Room)

// Room Entity
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val email: String,
    @ColumnInfo(name = "created_at") val createdAt: Long
)

// Room DAO
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsers(): Flow<List<UserEntity>>

    @Insert
    suspend fun insert(user: UserEntity)
}

After (SQLDelight)

-- shared/src/commonMain/sqldelight/.../User.sq
CREATE TABLE UserEntity (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    created_at INTEGER NOT NULL
);

getAllUsers:
SELECT * FROM UserEntity;

insertUser:
INSERT INTO UserEntity (name, email, created_at) VALUES (?, ?, ?);

SQLDelight generates type-safe Kotlin code from these SQL files. The generated code works on all platforms.

Data Migration

If your app has existing users with data in Room, you need to handle the data migration:

  1. On the first launch after the update, read all data from Room
  2. Write it to the new SQLDelight database
  3. Delete the old Room database
// androidMain — one-time migration
class DatabaseMigrator(
    private val context: Context,
    private val sqlDelightDb: AppDatabase
) {
    fun migrateFromRoom() {
        val roomDb = Room.databaseBuilder(context, OldRoomDb::class.java, "old-db").build()
        val oldUsers = roomDb.userDao().getAllUsersSync()
        oldUsers.forEach { user ->
            sqlDelightDb.userQueries.insertUser(user.name, user.email, user.createdAt)
        }
        context.deleteDatabase("old-db")
    }
}

This migration code lives in androidMain because it uses Room (Android-only). iOS does not need it since there is no existing Room database on iOS.

Step 5: Move Business Logic

Business logic is the code between the UI and the data layer. It includes:

  • Use cases / interactors
  • Validation rules
  • Data transformations
  • State management (ViewModels)

Before (Android ViewModel)

// app/.../viewmodel/UserViewModel.kt
class UserViewModel(
    private val userRepository: UserRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    val users = userRepository.getAllUsers()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun createUser(name: String, email: String) {
        viewModelScope.launch {
            userRepository.createUser(name, email)
        }
    }
}

After (Shared ViewModel)

// shared/src/commonMain/.../domain/UserViewModel.kt
class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    val users = userRepository.getAllUsers()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun createUser(name: String, email: String) {
        viewModelScope.launch {
            userRepository.createUser(name, email)
        }
    }
}

The main change: remove SavedStateHandle and other Android-specific dependencies. If your ViewModel uses SavedStateHandle, extract the saved state logic into the platform layer.

KMP supports ViewModel and viewModelScope in commonMain (via the Jetpack Lifecycle KMP artifact). This is a recent addition that makes migration much easier.

Step 6: Create the iOS App

Once the shared module has your models, networking, database, and business logic, create the iOS app.

Set Up the Xcode Project

  1. In the KMP project, make sure the iOS framework builds:

    ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
    
  2. Create a new Xcode project (SwiftUI App)

  3. Add the shared framework:

    • Drag Shared.framework into the project
    • Or use a Gradle task to copy it automatically

Consume the Shared Module in SwiftUI

import Shared

struct ContentView: View {
    @StateObject private var viewModel = UserViewModelWrapper()

    var body: some View {
        List(viewModel.users, id: \.id) { user in
            Text(user.name)
        }
    }
}

The iOS app uses the same domain models, the same business logic, and the same data layer. Only the UI is new.

How Much iOS Code Do You Need?

For a typical app:

  • SwiftUI views — rebuild the UI in SwiftUI (or use Compose Multiplatform for iOS)
  • ViewModel wrappers — thin wrappers that bridge Kotlin Flow to SwiftUI’s ObservableObject
  • Platform module — Koin module providing iOS-specific implementations (database driver, Ktor engine)
  • App entry point — the SwiftUI @main struct

The iOS app is smaller than the Android app because all the business logic is shared. You are only building the UI and the platform glue.

Common Pitfalls

1. Trying to Migrate Everything at Once

The biggest mistake is trying to move all code to the shared module in one step. This leads to a long, unstable branch and a lot of broken builds.

Instead: migrate one layer at a time. Domain models first, then networking, then database, then ViewModels. Keep the Android app working after each step.

2. Android-Specific Types Leaking Into Shared Code

Watch out for these Android-only types that cannot go into commonMain:

  • android.content.Context
  • android.os.Parcelable
  • android.net.Uri
  • java.io.File
  • java.util.Date (use kotlin.time.Instant instead)
  • android.graphics.Bitmap

If your shared code needs these, create interfaces in commonMain and implement them with expect/actual.

3. Third-Party SDK Compatibility

Not all Android libraries have KMP equivalents:

Android LibraryKMP Replacement
RetrofitKtor Client
RoomSQLDelight
SharedPreferencesDataStore KMP
Hilt/DaggerKoin
Gson/Moshikotlinx-serialization
TimberKermit
Glide/CoilCoil (KMP version available)
FirebaseNo direct equivalent (keep in androidMain, use wrapper)
Play ServicesNo equivalent (keep in androidMain)

For libraries without KMP equivalents, keep them in androidMain and create expect/actual abstractions if iOS needs similar functionality.

4. Coroutine Scope Management

On Android, viewModelScope is tied to the Android lifecycle. In KMP, viewModelScope works in commonMain (via the Lifecycle KMP artifact), but you need to make sure coroutines are cancelled properly on iOS.

The FlowCollector helper we built in Part 15 handles this:

// commonMain
class FlowCollector<T>(
    private val flow: Flow<T>,
    private val onEach: (T) -> Unit
) : Closeable {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    private val job = scope.launch { flow.collect { onEach(it) } }
    override fun close() { job.cancel() }
}

On iOS, you call close() in deinit to cancel the collection:

deinit {
    collector?.close()
}

5. Build Time Increase

Kotlin/Native (iOS) compilation is slower than Kotlin/JVM (Android). Your CI build will take longer. To manage this:

  • Cache Gradle and Konan directories in CI
  • Use linkDebugFrameworkIosSimulatorArm64 for development (faster than release)
  • Consider building iOS only on push to main (not on every commit)

Migration Timeline

Here is a realistic timeline for a medium-sized Android app (50-100 screens):

WeekWhat to Do
1Create shared module, move domain models
2-3Migrate networking from Retrofit to Ktor
4-5Migrate database from Room to SQLDelight
6-7Move business logic and ViewModels
8Set up Koin DI in shared module
9-10Build iOS app skeleton, connect to shared module
11-14Build iOS UI screens in SwiftUI
15-16Testing, bug fixes, polish

For a smaller app (10-20 screens), you can cut this in half. The key is to keep shipping the Android app while migrating. Every step should be a merge-able PR that does not break the Android build.

Gradual Adoption Checklist

  • Create shared module with commonMain, androidMain, iosMain
  • Move domain models to commonMain
  • Move or recreate networking in Ktor (commonMain)
  • Move or recreate database in SQLDelight (commonMain)
  • Move business logic / ViewModels to commonMain
  • Set up Koin DI in shared module
  • Handle data migration for existing Android users
  • Build iOS shared framework
  • Create iOS project in Xcode
  • Build iOS UI with SwiftUI
  • Set up CI/CD for both platforms
  • Test on real devices (both platforms)

Source Code

Full source code for this tutorial: GitHub — tutorial-20-migration

Series Complete

This is the last tutorial in the KMP series. Over 20 tutorials, we went from “What is Kotlin Multiplatform?” to building a real cross-platform app with shared networking, database, business logic, and platform-native UI.

What You Learned

  • Parts 1-5: KMP foundations — setup, project structure, Compose Multiplatform, framework comparison
  • Parts 6-10: Core libraries — Ktor, SQLDelight, DataStore, Koin, shared ViewModel
  • Parts 11-14: Architecture — clean architecture, navigation, testing, error handling
  • Parts 15-18: Real app — planning, data layer, UI layer, publishing
  • Parts 19-20: Advanced — desktop/web targets, migration from Android

Where to Go From Here

  • Add more features to the notes app (tags, reminders, images)
  • Try Compose Multiplatform for iOS (shared UI instead of SwiftUI)
  • Add a desktop target and build a macOS version
  • Explore Kotlin/Wasm for a web companion
  • Migrate one of your existing Android apps using the strategy from this tutorial

The KMP ecosystem is growing fast. New libraries add multiplatform support regularly. The skills you built in this series will serve you well as the ecosystem matures.