Chapter 2 — How Kotlin Multiplatform Actually Works
Where you are in the book. In Chapter 1 I made a claim and asked you to take it on faith: KMP compiles your Kotlin to genuinely native binaries, with no bridge and no runtime in the middle. This chapter pays off that promise by showing you the machinery. You'll write a little code here, but the goal isn't to build anything yet — it's to install a mental model so solid that when you open any KMP project in Chapter 3, every folder and every keyword already makes sense. This is the chapter the rest of the book stands on. Read it slowly.
The magic trick, explained
Every good magic trick has a method, and once you see the method the magic doesn't disappear — it becomes something you can do yourself.
KMP's trick is this: the same Kotlin source file produces a JVM .class that runs on an Android phone and a native iOS framework that runs on an iPhone, and somehow your shared code can still reach platform-specific capabilities when it needs them. How? There is no bridge, so how does common code that "knows nothing about Android or iOS" end up doing platform-specific things like reading the OS version or opening a database?
The answer comes down to four ideas, and that's genuinely all there is:
- Source sets — where your code lives, and what each piece is allowed to see.
- Targets — what each piece compiles down to.
expect/actual— the mechanism that lets common code reach for platform-specific behavior safely.- klib and Gradle — the plumbing that packages and wires it all together.
Understand these four and you understand KMP. Everything else in this book — Ktor, Room, Koin, maps, sensors — is just these four ideas applied to real problems. Let's take them one at a time.
The one idea that unlocks everything: common code vs platform code
Here is the central split, and if you internalize nothing else from this chapter, internalize this.
Your code lives in one of two worlds:
Common code is platform-agnostic Kotlin. It does not know whether it's running on Android or iOS. It cannot call
android.os.Build, and it cannot callUIDevice. It can only use Kotlin itself and libraries that are also platform-agnostic. This is where the majority of your app lives — your business logic, your networking, your data models, your view models.Platform code is Kotlin that targets one specific platform and therefore can see that platform's APIs. Android platform code can call the Android SDK. iOS platform code can call UIKit, Foundation, and the rest of Apple's frameworks directly from Kotlin.
The relationship between them has one rule that governs everything:
Common code can only use what's available on every target. Platform code can use everything common code can, plus its own platform's APIs.
Think of it as concentric circles of visibility. Common code sits in the smallest circle — the most restricted, because it has to work everywhere. Each platform sits in a larger circle that contains common plus its own native world.
┌─────────────────────────────────────┐
│ androidMain │
│ (Android SDK + everything below) │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ commonMain │ │
│ │ (pure Kotlin, no platform) │ │
│ │ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ iosMain │
│ (UIKit, Foundation + everything below)│
│ ┌─────────────────────────────────┐ │
│ │ commonMain │ │
│ │ (the *same* common code) │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
Notice that commonMain appears in both pictures — it's the same code, shared, sitting inside every platform's world. The platform source sets wrap around it and add native powers. This is the whole architecture in one image.
Now let's give these "worlds" their real name: source sets.
Source sets: where your code lives, and what it can see
A source set is not just a folder. It's tempting to think of commonMain as "the common folder," but that undersells it. A source set is a unit of compilation with its own dependencies and its own view of which APIs exist. When you put a file in commonMain, you're not just choosing a directory — you're declaring "this code must work on every platform, so the compiler should refuse to let me touch anything platform-specific."
That last part is the magic. The compiler enforces the visibility rule. If you're working in commonMain and you type android.os.Build, the code won't compile — not because of a lint rule or a convention, but because, from commonMain's point of view, that class genuinely does not exist. The Android SDK isn't on the menu, because commonMain has to be able to serve the iOS kitchen too.
A standard KMP module starts with these source sets:
src/
├── commonMain/ ← pure Kotlin, runs everywhere. Your code lives here by default.
│ └── kotlin/
├── androidMain/ ← Android-specific Kotlin. Can call the Android SDK.
│ └── kotlin/
├── iosMain/ ← iOS-specific Kotlin. Can call UIKit, Foundation, etc.
│ └── kotlin/
├── commonTest/ ← tests that run on every platform
│ └── kotlin/
├── androidUnitTest/ ← Android-only tests
└── iosTest/ ← iOS-only tests
The discipline this structure encourages is the discipline of good KMP code, and it's worth saying out loud as a rule you'll follow for the rest of the book:
Write in
commonMainby default. Drop into a platform source set only when the compiler forces you to.
Most developers new to KMP instinctively reach for platform code too early, out of old habit. Resist it. Start every piece of functionality in commonMain. The moment you actually need something only a platform can give you, the compiler will stop you — and that's your signal to move that one small piece down into a platform source set. The friction is a feature: it keeps your shared layer as large as it honestly can be.
The source set hierarchy: why iosMain exists
Here's a wrinkle that confuses people, and clearing it up will make the project structure in Chapter 3 feel obvious instead of arbitrary.
"iOS" is not a single compile target. To actually ship an iOS app, your Kotlin has to compile for three different processor/environment combinations:
iosArm64— real iPhones and iPads (ARM64 processors).iosSimulatorArm64— the simulator running on Apple Silicon Macs (ARM64, but a simulator ABI).iosX64— the simulator running on older Intel Macs (x86-64).
These are genuinely different binaries. So now ask yourself: if you write a piece of iOS-specific code — say, reading the iOS version number — do you want to write it three times, once for each of these targets? Of course not. That code is identical across all three; the only thing that differs is the chip it eventually compiles for.
This is exactly the problem source sets solve, applied one level down. KMP gives you intermediate source sets — source sets that sit between commonMain and the individual targets, shared by a related group of targets. iosMain is one of these: it's a single place to write code shared by all three iOS targets. Below it can sit appleMain (shared by iOS, macOS, watchOS, tvOS) and nativeMain (shared by all Kotlin/Native targets). The full picture looks like a tree:
commonMain
(everything shares this)
/ \
androidMain nativeMain
(JVM/Android) (all native targets)
│
appleMain
(all Apple platforms)
│
iosMain
(all three iOS targets share this)
/ | \
iosArm64 iosSimulatorArm64 iosX64
(device) (M-series sim) (Intel sim)
You almost never write code in those bottom three leaf source sets. You write iOS code once in iosMain, and it automatically flows down to all three. The tree exists so that "shared across a family of targets" is just as easy as "shared across all targets."
The good news: modern Kotlin sets this hierarchy up for you automatically. There's a built-in "default hierarchy template" that wires iosMain, appleMain, nativeMain, and the rest into place the moment you declare your targets. You don't draw this tree by hand; you just declare "I target Android and iOS," and the intermediate source sets appear. I'm showing you the tree not because you'll build it manually, but because when you see iosMain in your project and wonder "wait, which iOS?", the answer is now obvious: all of them.
Targets: what your code compiles to
A target is a compilation destination — the actual platform-and-architecture your source sets get turned into runnable code for. Where source sets are about organizing code by visibility, targets are about producing binaries.
The transformation is genuinely different on each side, and this difference is the heart of why KMP feels familiar to Android developers and slightly mysterious on the iOS side:
The Android target compiles to JVM bytecode. This is nothing new. When you declare an Android target, your commonMain + androidMain code compiles to ordinary .class files, packaged into the .aar/.jar artifacts you've shipped your whole Android career. There is no Kotlin/Native involved here, no LLVM, no exotic toolchain — it's the same Kotlin-to-JVM compilation you already do every day. On Android, KMP is just Kotlin. That's why the Android half of everything in this book will feel like home.
The iOS targets compile to native binaries through Kotlin/Native. This is the genuinely new machinery. Your commonMain + iosMain code is fed through Kotlin/Native, which uses LLVM — the same compiler infrastructure behind Swift and Clang — to produce a real, native .framework. Not bytecode interpreted by a VM. Not JavaScript. A compiled native binary with the same execution characteristics as the Swift code sitting next to it. This is the technical fact that backs up Chapter 1's "no bridge, native performance" claim: there's no bridge because, after compilation, your shared logic is native iOS code.
The relationship between source sets and targets is many-to-one in a specific way: a single source set can feed multiple targets, and a single target draws from multiple source sets. When the Android target compiles, it pulls in commonMain and androidMain. When the iosArm64 target compiles, it pulls in commonMain, nativeMain, appleMain, and iosMain — its whole ancestry in the tree. The source sets are the ingredients; the targets are the dishes; each dish uses several ingredients, and some ingredients (commonMain) go into every dish.
expect/actual: the mechanism for "write once, adapt per platform"
We've now reached the cleverest part, and the part that makes the whole thing work.
We've established a tension. Common code can't see platform APIs — that's the rule that keeps it portable. But sometimes common code genuinely needs a platform to do something for it. Your shared logic might need a universally-unique ID, or the current platform's name, or — as we'll hit for real later — a database driver or an HTTP engine. Each platform has a perfectly good way to provide these, but the way is different on each. Android generates a UUID one way; iOS does it another. Both can do it; neither does it identically.
expect/actual is KMP's answer. It lets common code declare "I expect this capability to exist; each platform must provide it."
You declare the shape of what you need in common code with the expect keyword — no implementation, just the signature, like an abstract member:
// commonMain — "I expect every platform to give me a name."
expect fun platformName(): String
Then each platform source set provides the real implementation with the actual keyword, using that platform's native APIs:
// androidMain — the Android way
import android.os.Build
actual fun platformName(): String =
"Android API ${Build.VERSION.SDK_INT}"
// iosMain — the iOS way, calling UIKit directly from Kotlin
import platform.UIKit.UIDevice
actual fun platformName(): String {
val device = UIDevice.currentDevice
return device.systemName() + " " + device.systemVersion
}
Now — and this is the beautiful part — your common code can simply call platformName(), completely unaware of which implementation will run. At compile time, the Android target wires the call to the Android actual, and the iOS targets wire it to the iOS actual. Same call site in shared code; different native implementation underneath; the seam is invisible to the code that uses it.
Look closely at the iOS actual, because something quietly remarkable is happening: we're calling UIDevice.currentDevice — a UIKit API — directly from Kotlin, with no wrapper and no bridge. Kotlin/Native exposes Apple's frameworks to Kotlin as importable packages (platform.UIKit, platform.Foundation, and so on). When you're in iosMain, the entire iOS SDK is available to you in Kotlin. That's not a translation layer; it's direct interop, compiled natively. The first time you write UIKit calls in Kotlin it feels uncanny. You get used to it.
The compiler guarantee you get for free
Here's why expect/actual is safe rather than scary: the compiler refuses to build if any target is missing an actual. If you declare expect fun platformName() and forget to provide the iOS implementation, your iOS build fails immediately, at compile time, with a clear error — not at runtime in front of a user. You physically cannot ship a half-implemented platform contract. The double-work problem from Chapter 1 had drift bugs precisely because nothing forced the two implementations to stay in sync. expect/actual is the enforcement that was missing: one declaration, and the compiler stands guard over every platform until the contract is honored everywhere.
expect/actual comes in several shapes
You can expect more than functions. The mechanism works for whole declarations:
// expect a property
expect val platformName: String
// expect a class (each platform provides the real one)
expect class HttpEngineFactory() {
fun create(): HttpEngine
}
// expect an object
expect object Analytics {
fun track(event: String)
}
There's also a useful shortcut: a platform can satisfy an expect class by pointing an actual typealias at an existing platform class, instead of writing a new one. This is how a lot of the library ecosystem maps common abstractions onto pre-existing native types — you'll see it, and now it won't surprise you:
// commonMain
expect class UUID
// androidMain — reuse java.util.UUID directly
actual typealias UUID = java.util.UUID
When not to reach for expect/actual (the honest version)
If I left it there, you'd walk away thinking expect/actual is the tool for every platform difference. It isn't, and over-using it is one of the most common mistakes in real KMP codebases. So let me give you the judgment now, even though we won't fully exploit it until later chapters.
expect/actual has real weaknesses once you push past simple, leaf-level values:
- It binds one common declaration to exactly one implementation per platform. There's no room for a second implementation, which is exactly what you want in tests. You can't easily swap in a fake.
- It doesn't compose. An
expect classwith dependencies of its own gets awkward fast, becauseexpect/actualhas no natural way to receive those dependencies. - It scatters a single concept across multiple source sets, which is fine for a one-liner and painful for anything with real behavior.
For anything beyond a small, dependency-free, leaf-level primitive, the better pattern — and the one this book will use for maps, sensors, location, logging, and the rest — is plain old interfaces in common code, with platform implementations provided through dependency injection. Same idea, more flexible mechanism:
// commonMain — an ordinary interface, no expect/actual
interface PlatformInfo {
val name: String
}
// androidMain
class AndroidPlatformInfo : PlatformInfo {
override val name = "Android API ${Build.VERSION.SDK_INT}"
}
// iosMain
class IOSPlatformInfo : PlatformInfo {
override val name =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
Common code depends on the PlatformInfo interface; the right implementation gets handed to it at runtime (by Koin, once we get there in Chapter 11). This composes cleanly, accepts dependencies naturally, and — crucially — lets you inject a fake PlatformInfo in a test without any platform at all.
So here's the rule to carry forward:
Use
expect/actualfor small, self-contained, dependency-free platform primitives. Use a common interface + dependency injection for anything with real behavior, dependencies, or that you'll want to fake in a test.
We dig into this trade-off properly in Chapter 13, where we design the platform abstractions for Atlas's location and sensor features. For now, just know both tools exist and that expect/actual is the precise instrument, not the default hammer.
klib: the format that makes shared code shippable
A quick but important stop. You know that on the JVM, a compiled library is a .jar. But a .jar is a JVM artifact — it means nothing to a native iOS binary. So how does a multiplatform library — say, Ktor — ship a single dependency that can serve both your JVM target and your native iOS targets?
The answer is klib (Kotlin library), Kotlin's own platform-neutral library format. A klib carries the common and Kotlin/Native pieces of a library in a form the Kotlin compiler can consume when building for any target. It's the reason you can add one line to commonMain and have that dependency available on Android and iOS alike.
You will rarely, if ever, manipulate a klib by hand. The reason I'm naming it is so the concept isn't a black box: when you add a multiplatform dependency and it "just works" across platforms, klib is the format making that possible. There's even a catalog, klibs.io, for discovering which libraries publish multiplatform artifacts — which matters a great deal, because (as we'll see in a moment) not every library does. File "klib" under know it exists; we'll never need more than that.
Gradle: how it's all wired together
Source sets, targets, and dependencies don't configure themselves. The thing that declares "this module targets Android and iOS, here are its source sets, here's what each one depends on" is your Gradle build script, via the Kotlin Multiplatform Gradle plugin. You'll meet a real one in Chapter 3; here I just want you to be able to read one, because once you can read the kotlin { } block, a KMP project has no more secrets.
A representative shared module's build script, trimmed to its essence:
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
// 1. Declare your targets — what we compile to.
androidTarget()
iosArm64() // real devices
iosSimulatorArm64() // Apple Silicon simulator
iosX64() // Intel simulator
// 2. Declare dependencies per source set.
sourceSets {
commonMain.dependencies {
// Must be multiplatform-capable — available on every target.
implementation(libs.ktor.client.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
// Android-only: the OkHttp-based Ktor engine.
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
// iOS-only: the Darwin-based Ktor engine.
implementation(libs.ktor.client.darwin)
}
}
}
Read it top to bottom and the whole model is right there. First you declare your targets (androidTarget, the three iOS ones — declaring them is also what triggers the default hierarchy template to build that source set tree from earlier). Then you declare dependencies per source set: commonMain gets the shared, platform-neutral libraries; each platform source set gets its own platform-specific pieces.
That split in the dependencies is teaching you something real about how the modern KMP stack is built. Notice Ktor isn't one dependency — it's a multiplatform core in commonMain plus a platform-specific engine in each platform source set (okhttp on Android, darwin on iOS). That's a recurring shape: a shared API surface in common, backed by a platform-appropriate implementation underneath. It's expect/actual thinking, applied at the library level. You'll see the same core-plus-engine pattern when we wire up real networking in Chapter 8.
And one rule that pattern enforces, which trips up every newcomer at least once:
A dependency you put in
commonMainmust itself be multiplatform. You cannot drop an Android-only library intocommonMainand hope — it won't resolve for the iOS targets, and your build will fail. Android-only libraries go inandroidMain; iOS-only ones iniosMain; only genuinely multiplatform libraries belong incommonMain.
Vetting whether a library is multiplatform-safe before you commit to it is a real skill, and it's important enough that Appendix C is devoted to it. For now, just hold the rule: the common circle only accepts dependencies that, like your own common code, can run everywhere.
Putting it together: the life of a shared function
Let's trace one function from source to running app, because watching the whole pipeline once is what makes the machinery finally click.
You write this in commonMain:
// commonMain
fun greeting(): String = "Hello from ${platformName()}!"
It calls our earlier expect fun platformName(). Now follow it down both paths:
On the Android path, the Android target compiles commonMain together with androidMain. The call to platformName() resolves to the Android actual, which calls Build.VERSION.SDK_INT. All of it compiles to JVM bytecode, lands in your .aar, and runs on the device exactly like any Kotlin you've ever shipped. A user on an Android phone sees "Hello from Android API 34!"
On the iOS path, the iosArm64 target compiles commonMain together with iosMain (and its appleMain/nativeMain ancestry). The same platformName() call resolves to the iOS actual, which calls UIDevice.currentDevice. Kotlin/Native runs the whole thing through LLVM into a native .framework, which your Xcode project links. A user on an iPhone sees "Hello from iOS 17.4!"
One source file. One call site. Two genuinely native binaries. No bridge between them at runtime — the platform difference was resolved at compile time, when each target picked its actual. That is the entire trick, and you now know the method.
How this changes the way you'll code
Before the summary, a few practical habits that fall out of this model. These will save you from the mistakes every KMP newcomer makes:
- Default to
commonMain. Put new code there first, always. Let the compiler tell you when you've reached for something it can't give you — that error is information, not an obstacle. - Treat a compile error in common code as a design prompt, not a nuisance. When
commonMainwon't let you call a platform API, it's asking: "is this difference essential?" Usually the answer reshapes your code for the better — pushing the tiny platform-specific bit behind an interface and keeping the rest shared. - Keep platform code small and leaf-like. The ideal
androidMain/iosMainfile is a thin shim — a few lines that hand a native capability up to the shared layer. If a platform source set is growing large, that's a smell worth investigating. - Reach for
expect/actualsparingly, interfaces-plus-DI liberally. You now have both tools and the rule for choosing between them. Most of Atlas's platform seams will be interfaces.
Summary
The four ideas that are Kotlin Multiplatform:
- Source sets organize your code by visibility.
commonMainis the most restricted (pure Kotlin, runs everywhere); platform source sets likeandroidMainandiosMaincan see common code plus their own native APIs. The discipline is simple: write incommonMainby default, drop to a platform source set only when the compiler forces you. - Intermediate source sets (
iosMain,appleMain,nativeMain) let you share code across a family of related targets, so you write iOS code once even though there are three iOS targets. Modern Kotlin builds this hierarchy for you automatically. - Targets are compilation destinations. The Android target produces ordinary JVM bytecode — on Android, KMP is just Kotlin. The iOS targets produce native binaries via Kotlin/Native and LLVM — which is why there's no bridge and no performance penalty.
expect/actuallets common code declare a capability and have each platform implement it natively, with the compiler guaranteeing no target is left unimplemented. Use it for small, dependency-free platform primitives; for anything with real behavior or dependencies, prefer a common interface implemented per platform and injected via DI.- klib is Kotlin's multiplatform library format — the reason a single dependency can serve both your JVM and native targets. Gradle's
kotlin { }block declares your targets and your per-source-set dependencies, with the firm rule that anything incommonMainmust itself be multiplatform.
You now understand what every folder and keyword in a KMP project means and why it's there. That understanding is the foundation for everything ahead — and it's exactly what makes the next step painless.
In Chapter 3, we stop reading about the structure and start living in it: installing the tooling, creating your first multiplatform project, and running it on both an Android emulator and an iOS simulator. Because you already understand what the project structure means, the setup will feel like recognition rather than memorization. Let's build something that runs on two platforms at once.
End of Chapter 2.