Kotlin Multiplatform (KMP) Patterns
Project Structure
shared/
├── commonMain/kotlin/
│ ├── data/
│ │ ├── UserRepository.kt # interface
│ │ └── UserRepositoryImpl.kt # implementation using Ktor + SQLDelight
│ ├── domain/
│ │ └── UserUseCase.kt
│ └── platform/
│ └── Platform.kt # expect declarations
├── androidMain/kotlin/
│ └── platform/
│ └── PlatformAndroid.kt # actual declarations
└── iosMain/kotlin/
└── platform/
└── PlatformIos.kt # actual declarations
androidApp/ # Android app module
iosApp/ # Xcode project
expect / actual
// commonMain
expect class Platform() {
val name: String
}
expect fun currentTimeMillis(): Long
expect class FileStorage(path: String) {
fun read(): String
fun write(content: String)
}
// androidMain
actual class Platform actual constructor() {
actual val name: String = "Android ${android.os.Build.VERSION.RELEASE}"
}
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
actual class Platform actual constructor() {
actual val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun currentTimeMillis(): Long = NSDate().timeIntervalSince1970.toLong() * 1000
Ktor in commonMain
// commonMain — same HTTP client for all platforms
class UserApiClient(private val httpClient: HttpClient) {
suspend fun getUser(id: Long): User = httpClient.get("/users/$id").body()
suspend fun createUser(request: CreateUserRequest): User =
httpClient.post("/users") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
// Platform-specific engine injection
// androidMain:
val client = HttpClient(Android) { install(ContentNegotiation) { json() } }
// iosMain:
val client = HttpClient(Darwin) { install(ContentNegotiation) { json() } }
SQLDelight
-- commonMain/sqldelight/User.sq
CREATE TABLE user (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
findById:
SELECT * FROM user WHERE id = :id;
insert:
INSERT INTO user(id, name, email, created_at) VALUES (?, ?, ?, ?);
findAll:
SELECT * FROM user ORDER BY created_at DESC;
// Usage in commonMain
class LocalUserDataSource(private val db: AppDatabase) {
fun findById(id: Long): User? = db.userQueries.findById(id).executeAsOneOrNull()
fun insert(user: User) = db.userQueries.insert(user.id, user.name, user.email, user.createdAt)
}
// Database factory — expect/actual for driver
expect fun createDatabase(name: String): AppDatabase
// androidMain
actual fun createDatabase(name: String): AppDatabase =
AppDatabase(AndroidSqliteDriver(AppDatabase.Schema, context, "$name.db"))
// iosMain
actual fun createDatabase(name: String): AppDatabase =
AppDatabase(NativeSqliteDriver(AppDatabase.Schema, "$name.db"))
Shared ViewModel (KMP-friendly)
// commonMain — no Android dependency
open class UserViewModel(private val useCase: UserUseCase) : CoroutineViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Loading)
val state: StateFlow<UserState> = _state.asStateFlow()
fun loadUser(id: Long) = viewModelScope.launch {
runCatching { useCase.getUser(id) }
.onSuccess { _state.value = UserState.Success(it) }
.onFailure { _state.value = UserState.Error(it.message ?: "Unknown error") }
}
}
// iosMain — expose as ObservableObject via SKIE or KMMViewModel
build.gradle.kts (KMP)
kotlin {
androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } }
iosX64(); iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.sqldelight.runtime)
implementation(libs.kotlinx.coroutines.core)
}
androidMain.dependencies {
implementation(libs.ktor.client.android)
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
}
}
Key Rules
- Keep
commonMain free of platform APIs — any platform-specific code must go through expect/actual
- Use
StateFlow in shared ViewModels; iOS can collect it via SKIE (@NativeCoroutines) or Combine bridge
- SQLDelight generates type-safe Kotlin from SQL — write SQL first, then call generated queries
- Freeze objects only when using the old memory model; with the new default memory model (Kotlin 1.7.20+) freezing is largely unnecessary
- Prefer SKIE or KMMViewModel library for iOS ViewModel exposure — raw coroutine bridging to Swift is error-prone