Kotlin Multiplatform Basics
An introduction to Kotlin Multiplatform: what it is, how shared code is structured, and a first look at expect/actual declarations.
Kotlin Multiplatform Basics
Kotlin Multiplatform (KMP) is JetBrains' approach to code sharing across platforms. Instead of rewriting the same business logic in Swift for iOS and Kotlin for Android, KMP lets you write that logic once in Kotlin and compile it to run natively on both ā alongside the web, desktop, and server, if you need it there too.
It's a different philosophy from frameworks like Flutter or React Native. Those render their own UI layer on top of a shared runtime. KMP takes the opposite approach: the UI stays fully native (SwiftUI on iOS, Jetpack Compose or the native Android View system on Android), and only the parts that don't need to be platform-specific ā networking, data models, business rules, persistence ā are shared.
Why share only the logic?
Native UI has real costs when you write it twice, but it also has real benefits: platform-accurate animations, accessibility behavior, and day-one support for new OS features. KMP is a bet that the logic layer is where duplication hurts most (subtle bugs creeping in when two implementations drift apart) while the UI layer benefits most from staying native.
In practice, teams adopting KMP usually share:
- API clients and networking code
- Data models and serialization
- Local database access and caching logic
- Validation rules and business logic
- View models / presentation logic (with Compose Multiplatform, even more of the UI can be shared)
Project structure
A typical KMP module is organized into source sets:
shared/
āāā src/
ā āāā commonMain/ # Shared Kotlin code, compiled for every target
ā āāā androidMain/ # Android-specific implementations
ā āāā iosMain/ # iOS-specific implementations
ā āāā commonTest/ # Shared tests
Code in commonMain is the default ā anything that doesn't need platform-specific APIs lives there. When you do need something platform-specific (reading a device ID, accessing the keychain, using a platform HTTP client), you declare an expect in commonMain and provide an actual implementation in each platform source set.
The expect/actual mechanism
This is the core mechanism that makes cross-platform code possible without leaky abstractions. Declare what you need in shared code:
// commonMain
expect class PlatformInfo() {
val name: String
}
expect fun getPlatformInfo(): PlatformInfo
Then provide a real implementation per platform:
// androidMain
actual class PlatformInfo actual constructor() {
actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
actual fun getPlatformInfo(): PlatformInfo = PlatformInfo()
// iosMain
import platform.UIKit.UIDevice
actual class PlatformInfo actual constructor() {
actual val name: String = UIDevice.currentDevice.systemName()
}
actual fun getPlatformInfo(): PlatformInfo = PlatformInfo()
Common code can now call getPlatformInfo() without knowing or caring which platform it's running on. The compiler makes sure every declared expect has a matching actual for each target, so there's no risk of a silently missing implementation.
A shared network call, end to end
A simple shared repository might look like this, using Ktor's multiplatform HTTP client:
// commonMain
class UserRepository(private val client: HttpClient) {
suspend fun fetchUser(id: String): User =
client.get("https://api.example.com/users/$id").body()
}
This same class, and the coroutine-based suspend fun, compiles and runs unchanged on both Android and iOS. Ktor's engine swaps out the underlying HTTP implementation per platform (OkHttp on Android, NSURLSession on iOS) behind a common API ā you never see the difference in shared code.
Where this fits with Compose Multiplatform
Kotlin Multiplatform and Compose Multiplatform are related but separate: KMP shares logic while native UI frameworks render each platform's screens, whereas Compose Multiplatform extends JetBrains' declarative UI toolkit to also share UI code across Android, iOS, desktop, and web. Many teams start with KMP for the logic layer alone, since it's the lower-risk, incremental adoption path ā you can introduce it into an existing native codebase one module at a time without touching the UI at all.
Getting started
The fastest way to try it is the Kotlin Multiplatform wizard, which scaffolds a shared module plus Android and iOS app targets with a working expect/actual example already wired up. From there, it's a matter of moving existing logic into commonMain piece by piece and letting the compiler point out what still needs a platform-specific actual.
That incremental path is what makes KMP practical for real projects: you don't need to commit to a full rewrite to start getting value from shared code.