Suppose your school-fees app is a success on Android, and the bursar's office asks for an iPhone version, and the head teacher wants a desktop version for the school computer. The traditional answer is to write the app again in Swift for iOS, with its own bugs, its own business rules and its own team. When the fee rule changes, you change it twice, and the two apps slowly drift apart.
Kotlin Multiplatform (KMP) offers another answer: write the parts that are the same once, in Kotlin, and compile them for every platform, while keeping each platform's own UI and system APIs where that matters. This lesson opens the Kotlin Multiplatform programme at https://developer.android.com/courses/pathways/kotlin-multiplatform (Why you should adopt Kotlin Multiplatform and Get started with Kotlin Multiplatform). The programme links to the JetBrains documentation at https://www.jetbrains.com/help/kotlin-multiplatform-dev/ and to Google's guidance at https://developer.android.com/kotlin/multiplatform.
By the end you will be able to:
- Explain what KMP is and how it differs from "write once, run anywhere" frameworks.
- Describe how Kotlin compiles to each target.
- Decide what to share and what to keep native.
- Use
expect/actualand interfaces to reach platform APIs. - Compare KMP, Compose Multiplatform, Flutter and React Native fairly.
- Make an honest adopt / do not adopt decision for a project.
Big idea: KMP is about sharing the right code, not all the code. Share the logic that is identical, and let each platform keep what is special.
Honesty note: the KMP tooling changes quickly (Gradle plugins, the Android target, which libraries support which targets). This lesson explains stable ideas and shows code in the style of current documentation, but check the official pages for the current setup before starting a real project. The ten-style list of reasons below is my own summary of the common arguments; JetBrains publishes its own list at https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-reasons-to-try.html, and you should read it too.
- Kotlin classes, interfaces and functions (lessons 6 to 14).
- Coroutines and Flow (lesson 33).
- App architecture and layers (lessons 60 to 65), because the layered design decides what is easy to share.
- Gradle Kotlin DSL and version catalogs (lessons 3 and 34).
- Room and Retrofit (lessons 29 and 34), which you will meet again in KMP form.
One language, many targets
A target is a platform your code is compiled for. Kotlin can compile to several:
| Target | Kotlin compiles to | Typical use |
|---|---|---|
| Android | JVM bytecode (then Android's runtime) | Your Android app |
| JVM / Desktop | JVM bytecode | Desktop apps, servers |
| iOS (and macOS, watchOS, tvOS) | Native machine code via Kotlin/Native (LLVM) | iPhone and iPad apps |
| Web | JavaScript or WebAssembly | Browser apps |
| Linux, Windows | Native code | Command-line and desktop tools |
You write common Kotlin that uses only the Kotlin standard library and multiplatform libraries. The Kotlin compiler produces the correct output for each target. On iOS your shared code becomes a framework that Swift code imports, like any other iOS library.
Where the code lives
A KMP module is split into source sets:
shared/
src/
commonMain/kotlin/ code for ALL targets
commonTest/kotlin/ tests for common code
androidMain/kotlin/ Android-only code
iosMain/kotlin/ iOS-only code
Common code cannot call Android or iOS APIs directly, because it must compile everywhere. When it needs a platform feature, you use expect/actual or an interface (below).
What KMP is not
- It is not a UI toolkit. By default, your Android app still uses Compose and your iOS app still uses SwiftUI. (Compose Multiplatform is a separate JetBrains framework that can share UI as well; see the comparison below.)
- It is not "all or nothing". You can share a single module, a single class, or the whole data layer, in an existing app.
- It does not hide the platform. You still build and ship an Android app and an iOS app, each with its own entry point.
The usual arguments, in my own words:
| Reason | What it means |
|---|---|
| Share business logic | Fee rules, validation and sync run on both platforms and cannot drift apart |
| Native UI stays native | Each platform keeps its own look, accessibility and performance |
| Incremental adoption | Start with one module in an app you already have |
| One language and one set of skills | Android developers read and review the shared code |
| Fewer duplicate bugs | Fix once; both apps benefit |
| Shared tests | Test business rules once in commonTest |
| Real libraries exist | Coroutines, serialization, Ktor, Room, DataStore and more support multiple targets |
| Strong backing | JetBrains develops it; Google supports KMP for sharing business logic on Android and iOS |
| Interop, not a sandbox | You can call platform APIs and use platform libraries when needed |
| Freedom to share more later | Move from logic-only to shared UI with Compose Multiplatform if it suits you |
Limits to be honest about:
- iOS builds need a Mac with Xcode, always. You can learn and ship the Android half on any computer, but running the iOS app needs Apple's tools.
- Not every library is multiplatform. Some Android libraries have no iOS version, so you find alternatives or use
expect/actual. - Build setup is more complex than a single-platform app, and tooling changes between releases.
- The team needs to cooperate. iOS developers must be willing to use a Kotlin-built framework, and Kotlin/Swift interop has some rough edges (for example with generics and sealed classes).
- It is not always worth it. An app with little shared logic, or a team already happy with two native codebases, may gain little.
Business logic that needs nothing special
Our FeeTracker rule from lesson 63 uses only Kotlin, so it can move to commonMain unchanged:
// shared/src/commonMain/kotlin/com/example/fees/FeeSummary.kt
package com.example.fees
data class FeeSummary(val paid: Long, val balance: Long) {
val isOwing: Boolean get() = balance > 0
}
class CalculateFeeSummary(private val termFee: Long = 450_000) {
operator fun invoke(payments: List<Long>): FeeSummary {
val paid = payments.sum()
return FeeSummary(paid = paid, balance = termFee - paid)
}
}
Why this works: no Android imports, no java.* classes (those do not exist on iOS), just Kotlin's standard library. This is the strongest argument for the plain Kotlin domain layer in lesson 63: it is already shareable.
A test in commonTest runs on every target:
// shared/src/commonTest/kotlin/com/example/fees/FeeSummaryTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FeeSummaryTest {
@Test
fun balance_is_term_fee_minus_payments() {
val summary = CalculateFeeSummary(termFee = 100)(listOf(30, 20))
assertEquals(50, summary.balance)
assertTrue(summary.isOwing)
}
}
Notice kotlin.test, not JUnit: JUnit is a JVM library, so common tests use the multiplatform kotlin.test API. The same test runs on the JVM (Android host tests) and, on a Mac, on the iOS simulator.
What you cannot use in common code
Do not use in commonMain |
Use instead |
|---|---|
java.util.*, java.time.*, java.io.* |
kotlin.collections, kotlinx-datetime, kotlinx-io or Okio |
String.format(...) |
String templates, or a multiplatform formatting library |
android.* classes |
expect/actual or an interface |
Retrofit, OkHttp |
Ktor client |
| Gson or Moshi | kotlinx.serialization |
SharedPreferences |
DataStore (multiplatform) or an interface |
LiveData |
Flow / StateFlow |
Most of your lessons already used the multiplatform-friendly choices (coroutines, Flow, kotlinx.serialization), so your code is closer to KMP than you might think.
Sometimes common code needs something only the platform knows, such as the device name or the current time zone. Declare what you need in common code, and supply how on each platform.
// commonMain/Platform.kt
expect fun platformName(): String
fun greeting(): String = "Hello from ${platformName()}!"
// androidMain/Platform.android.kt
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain/Platform.ios.kt
import platform.UIKit.UIDevice
actual fun platformName(): String =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
expectis a promise: "every target will provide this".actualis the fulfilment in a platform source set. If a target lacks anactual, the build fails, so you cannot forget one.greeting()lives in common code and works everywhere, calling the rightactualat run time.- On iOS, Kotlin has access to Apple's frameworks through the
platform.*packages (hereUIKit).
expect/actual also works for classes:
// commonMain
expect class Clock() {
fun nowMillis(): Long
}
// androidMain
actual class Clock actual constructor() {
actual fun nowMillis(): Long = System.currentTimeMillis()
}
Often better: an interface
expect/actual is compile-time and rigid. For most application code, a plain interface in common code, with implementations supplied by each platform app, is more flexible and easier to test:
// commonMain
interface TokenStorage {
suspend fun read(): String?
suspend fun write(token: String)
}
class LoginUseCase(private val storage: TokenStorage, private val api: AuthApi) {
suspend operator fun invoke(user: String, password: String): Result<Unit> = runCatching {
val token = api.login(user, password)
storage.write(token)
}
}
// androidMain
class AndroidTokenStorage(private val context: Context) : TokenStorage {
override suspend fun read(): String? = /* EncryptedSharedPreferences or DataStore */ null
override suspend fun write(token: String) { /* ... */ }
}
// iosApp (Swift): Keychain implementation of the same Kotlin interface
class KeychainTokenStorage: TokenStorage {
func read() async throws -> String? { /* Keychain lookup */ return nil }
func write(token: String) async throws { /* Keychain save */ }
}
The shared LoginUseCase depends on the interface, not the platform. This is the dependency inversion idea from lessons 60 and 65 doing real work. A rule of thumb from experience: use expect/actual for tiny, stateless platform facts (a platform name, a UUID generator), and interfaces plus injection for services (storage, notifications, biometrics).
Understanding the pipeline removes much of the mystery.
commonMain ─┐
├─► compiled for Android ──► .class bytecode ─► your Android app
androidMain ─┘
commonMain ─┐
├─► compiled for iOS ─────► native code ───────► shared.framework ─► Xcode app
iosMain ─┘
- For each target, the Kotlin compiler combines
commonMainwith that target's own source set. - For Android and JVM, the output is bytecode, exactly as in any Android module.
- For iOS, Kotlin/Native compiles to machine code and packages it as a framework (and, with Swift Package Manager export, an XCFramework: lesson 69). Swift sees Kotlin classes as Objective-C-style classes with a generated header.
- Dependencies must also have a version for each target. A multiplatform library publishes a common variant plus per-target artifacts, and Gradle picks the right ones automatically.
The Gradle setup is a short kotlin { } block. You will study it line by line in lesson 67; here is a preview so that it does not look strange later:
// shared/build.gradle.kts (preview; see lesson 67 for the current Android target setup)
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
kotlin {
android {
namespace = "com.example.fees.shared"
compileSdk = 36
minSdk = 24
}
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
A practical way to choose, layer by layer, using the architecture from lessons 60 to 65:
| Layer | Share? | Notes |
|---|---|---|
| Domain (use cases, rules, models) | Yes, first | Plain Kotlin; highest value, lowest risk |
| Data: networking | Yes | Ktor client and kotlinx.serialization |
| Data: database | Often | Room supports KMP (lesson 70); SQLDelight is another popular option |
| Data: repositories | Yes | Depend on multiplatform sources |
| Preferences/storage | Yes with care | DataStore is multiplatform |
| UI-layer state holders (ViewModels) | Possible | Lifecycle ViewModel has multiplatform support; check the docs |
| UI | Optional | Native UI per platform, or Compose Multiplatform |
| Platform integrations (push, camera, biometrics, widgets) | No | Keep native behind interfaces |
Start small. The recommended adoption path is: (1) share a pure-Kotlin domain module, (2) share networking and models, (3) share the database, (4) consider shared state holders and, only if you want to, shared UI.
What a good first candidate looks like
- It has clear inputs and outputs (a function from payments to a summary).
- It has no UI and no platform calls.
- It is already tested and stable.
- Both teams care about consistency (money calculations, validation, date rules).
| Kotlin Multiplatform | Compose Multiplatform | Flutter | React Native | |
|---|---|---|---|---|
| Language | Kotlin | Kotlin | Dart | JavaScript/TypeScript |
| Shares | Logic (UI optional) | Logic and UI | Logic and UI | Logic and UI |
| UI approach | Native UI per platform | Compose-style UI drawn by the framework on each platform | Own rendering engine | Native views driven by JS |
| Native look and feel | Fully native | Compose look on every platform (customisable) | Own widgets, can imitate platforms | Native components |
| Adopt in an existing app | Very easy, module by module | Possible | Harder (embedding "add-to-app") | Possible |
| Best when | You have native apps and want to share logic | You want one UI codebase with Kotlin | Greenfield app, one team, custom UI | Web-skilled team, shared JS code |
Points of fairness:
- Compose Multiplatform is built on Kotlin Multiplatform and is developed by JetBrains. Its iOS support has been reaching stable status in recent releases; check the current status page before relying on any particular platform.
- None of these is "the best". A team of Android developers with an existing native app usually finds KMP the smoothest path; a startup with no native code and a small team may prefer a single-UI framework.
- Whatever you choose, measure with a small prototype before committing, as lesson 57 taught for performance.
Create a short decision record for a project. A good one answers:
Project: FeeTracker (Android today; iOS and desktop requested)
Shared code: domain rules, networking models, repositories, database
Native code: UI, notifications, camera for receipts
Team: 3 Android developers, 1 iOS developer
Risks: iOS developer unfamiliar with Kotlin framework; one Android library has no iOS version
Mitigation: pair on the first shared module; replace the library with a multiplatform one
First step: move CalculateFeeSummary and models to :shared, tests green on both platforms
Decision: Adopt incrementally. Review after the first release.
Writing it down forces honest answers and makes the review easy later.
Exercise 1: Shareable or not?
For each, decide whether it can live in commonMain as written, and what to change if not: (a) fun today() = java.time.LocalDate.now().toString(); (b) class Student(val id: Int, val name: String); (c) fun toNaira(kobo: Long) = kobo / 100; (d) val prefs = context.getSharedPreferences("x", 0).
(a) No: java.time is JVM-only. Use kotlinx-datetime's Clock.System.todayIn(TimeZone.currentSystemDefault()), or an interface. (b) Yes. (c) Yes. (d) No: Context and SharedPreferences are Android. Hide storage behind an interface (such as TokenStorage) or use multiplatform DataStore.
Exercise 2: expect/actual or interface?
Choose a mechanism for each: (a) returning the platform name; (b) a biometric prompt with a callback result; (c) generating a random UUID string.
(a) expect/actual function: tiny, stateless. (b) An interface (BiometricAuthenticator) implemented natively and injected, because it involves UI, lifecycle and callbacks. (c) expect/actual (or Kotlin's own kotlin.uuid.Uuid where available in your Kotlin version), because it is a small stateless utility.
Exercise 3: Honest advice
A friend wants to rewrite a working Android-only app in KMP "because everyone is doing it". The app has no iOS version planned. What do you advise?
Do not rewrite for fashion. Without a second platform, the main benefit (sharing) is absent, and the migration costs time and adds build complexity. Keep a clean layered design (lessons 60 to 65), so that moving the domain layer into a shared module later is cheap, and revisit KMP when a second platform becomes a real requirement.
Mistake #1: Importing java.* or android.* in common code. Fix: use multiplatform libraries or interfaces.
Mistake #2: Trying to share everything on day one. Fix: start with the domain layer.
Mistake #3: Using JUnit in commonTest. Fix: use kotlin.test.
Mistake #4: Overusing expect/actual. Fix: prefer interfaces for services.
Mistake #5: Forgetting the iOS side needs a Mac. Fix: plan hardware and CI early.
Mistake #6: Choosing libraries without checking target support. Fix: check klibs.io or the library's docs (lesson 69).
Mistake #7: Treating KMP as a UI framework. Fix: remember it shares logic; Compose Multiplatform shares UI.
Mistake #8: Copying outdated Gradle setups from old blog posts. Fix: follow the current official pages.
Mistake #9: Skipping tests in shared code. Fix: test in commonTest; one test runs on every target.
Mistake #10: Adopting without a decision record. Fix: write down scope, risks and a first step.
Question 1. What does KMP compile common Kotlin into for Android and for iOS?
JVM bytecode for Android, and native machine code packaged as a framework (via Kotlin/Native) for iOS.
Question 2. What is the difference between expect and actual?
expect declares in common code what is needed; actual provides the implementation in each platform source set.
Question 3. Why is the domain layer the best first candidate to share?
It is plain Kotlin with no platform dependencies, has high value (rules stay consistent) and is easy to test.
Question 4. Name two things KMP does not do.
It does not provide a UI toolkit by itself (that is Compose Multiplatform), and it does not remove the need for a Mac and Xcode to build iOS apps.
Question 5. Which test library works in commonTest?
kotlin.test.
Go deeper: official resources
- Kotlin Multiplatform programme
- Kotlin Multiplatform on Android Developers
- Why try Kotlin Multiplatform (JetBrains)
- Search multiplatform libraries on klibs.io
Project goal: Analyse one of your own apps and produce a KMP adoption plan, plus a small proof-of-concept of shareable code.
Requirements:
- Pick an app you have built in this course (FeeTracker, ClassRegister, or another).
- List every class and mark it Share, Share with changes or Keep native, with one line of reasoning.
- List every library you use and mark whether a multiplatform alternative exists (check klibs.io and each library's documentation).
- Write the decision record in the format of Build 6.
- Take one pure-Kotlin class and rewrite it using only the Kotlin standard library, so it could live in
commonMain. Write its test usingkotlin.test. - Write one
expect/actualpair and one interface-based design for two different platform needs, and justify each choice.
Stretch goal: estimate what percentage of your code is shareable by lines of code per layer, and explain which layer would give the best return.
A worked analysis for FeeTracker (example):
Class Verdict Reason
Payment, Student Share plain data classes
CalculateFeeSummary Share pure rule, tested
GetStudentBalancesUseCase Share depends only on repository interfaces
PaymentsRepository (iface) Share interface only
DefaultPaymentsRepository Share with changes swap Retrofit for Ktor, Room for Room KMP
PaymentApi (Retrofit) Share with changes rewrite as a Ktor client class
Room entities and DAOs Share with changes Room KMP (lesson 70)
FeeViewModel Share with changes use multiplatform lifecycle ViewModel, or keep native
Composables Keep native (or Compose Multiplatform later)
Notification code Keep native interface + Android/iOS implementations
A proof-of-concept class and test using only the standard library:
// commonMain
class Percentage(private val part: Long, private val whole: Long) {
init { require(whole > 0) { "whole must be positive" } }
fun asWholeNumber(): Int = ((part * 100) / whole).toInt()
}
// commonTest
class PercentageTest {
@Test fun half_is_fifty() = assertEquals(50, Percentage(50, 100).asWholeNumber())
@Test fun zero_whole_is_rejected() {
assertFailsWith<IllegalArgumentException> { Percentage(1, 0) }
}
}
Library check (example):
Retrofit/OkHttp -> Ktor client (multiplatform)
Gson/Moshi -> kotlinx.serialization
Room 2.x -> Room KMP (check setup page; lesson 70)
Hilt -> Android-only: keep DI per platform, or use Koin/kotlin-inject in shared code
Coil -> Coil 3 supports multiplatform (verify targets before relying on it)
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 66 project”.
On github.com click New repository, name it android-lesson-66-why-kotlin-multiplatform, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 66 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What Is Kotlin Multiplatform?
- Reasons to Adopt KMP (and Honest Limits)
- Step-by-Step Build 1: Common Code and Its Platforms
- Step-by-Step Build 2: Reaching the Platform with `expect`/`actual`
- Step-by-Step Build 3: How Compilation Works
- Step-by-Step Build 4: What to Share
- Step-by-Step Build 5: Fair Comparisons
- Step-by-Step Build 6: Your Own Adoption Decision
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check