03 · Multiplatform at Scale¶
Level 3's multiplatform module
introduced expect/actual with a single JVM target. A real
multiplatform app shares much more than one function: networking, DTOs,
business logic, and often local storage, across Android, iOS, desktop, and
web. This module shares a networking client and a key-value store across
commonMain, using multiplatform Ktor and per-platform storage — again
built against only the JVM target so it actually runs, with notes on how
each piece extends to more targets.
kotlin {
jvmToolchain(17)
jvm { withJava() }
// iosArm64(); iosSimulatorArm64() -- would add iOS, sharing commonMain
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
}
val jvmMain by getting {
dependencies { implementation("io.ktor:ktor-client-cio:2.3.12") }
// iosMain would instead add ktor-client-darwin here
}
}
}
Notice ktor-client-core (no -jvm suffix) in commonMain — that's the
multiplatform artifact coordinate; Gradle resolves it to the right
platform-specific jar per source set automatically.
Sharing a networking client via expect/actual engines¶
Ktor's HttpClient takes an engine — CIO, OkHttp, Darwin (iOS), Js — as
its first argument. commonMain code can stay engine-agnostic by asking
for the engine through an expect function.
// src/commonMain/kotlin/Common.kt
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
@Serializable
data class Repo(val name: String, val stargazers_count: Int)
expect fun httpClientEngine(): HttpClientEngineFactory<*>
class GitHubClient {
private val client = HttpClient(httpClientEngine()) {
install(ContentNegotiation) { json() }
}
suspend fun topKotlinRepoNames(sampleData: List<Repo>): List<String> =
sampleData.sortedByDescending { it.stargazers_count }.take(3).map { it.name }
fun close() = client.close()
}
// src/jvmMain/kotlin/Jvm.kt
import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*
actual fun httpClientEngine(): HttpClientEngineFactory<*> = CIO
// An iosMain equivalent would be: actual fun httpClientEngine() = Darwin
GitHubClient itself — connection setup, JSON config, and the actual
business logic in topKotlinRepoNames — is 100% shared. Only the one-line
engine choice is platform-specific, which is the entire point: the
amount of expect/actual code in a well-structured multiplatform app
should be small relative to the shared logic surrounding it.
Sharing storage the same way¶
Persisting a value looks completely different per platform (SharedPreferences
on Android, NSUserDefaults on iOS, java.util.prefs.Preferences here on
plain JVM) — another natural expect/actual boundary.
// commonMain
expect class PlatformStore() {
fun save(key: String, value: String)
fun load(key: String): String?
}
// jvmMain
import java.util.prefs.Preferences
actual class PlatformStore actual constructor() {
private val prefs = Preferences.userRoot().node("kmp-demo")
actual fun save(key: String, value: String) { prefs.put(key, value) }
actual fun load(key: String): String? = prefs.get(key, null)
}
Running both together:
(GitHubClient.topKotlinRepoNames sorts sample data rather than hitting
the real GitHub API, to keep this example deterministic and runnable
offline — swapping in a real client.get(...) call is a one-line change
once you have network access and an API you want to hit.)
Kotlin-specific traps¶
- Multiplatform Ktor artifact coordinates drop the
-jvmsuffix.commonMainusesio.ktor:ktor-client-core; a JVM-only module usesio.ktor:ktor-client-core-jvm. Copy-pasting a dependency line from a JVM-only tutorial into a KMPcommonMainblock is a common, hard-to-notice mistake (it often still resolves, to the wrong artifact metadata) that manifests as bizarre "expected multiplatform" errors. - Each engine needs its own dependency, chosen per platform. CIO and
OkHttp work on JVM/Android, Darwin only on Apple targets, Js only for
JS — forgetting to add the right engine dependency to a given platform
source set fails at compile time with a missing
actual, or at link time for Native targets. - A "shared" business logic function that secretly calls a JVM-only API compiles fine with a single target and only breaks the moment a second target (iOS, JS) is added — this is why testing multiplatform code means actually building for more than one target, not just trusting a single-target green build.
expect/actualinterfaces need matching default parameter handling carefully — defaults belong on theexpectdeclaration only; putting them on theactualside too is a compile error ("actual function cannot have default argument values").- Intermediate source sets (e.g. a shared
iosMainaboveiosArm64Main/iosSimulatorArm64Main) let you share Apple-specific but cross-target code without duplicating it — this hierarchical source set structure is exactly how a real large-scale KMP project (Compose Multiplatform apps, JetBrains's own libraries) avoids repeatingactualimplementations per Apple architecture.
How It Actually Works¶
ktor-client-core with no -jvm/-iosarm64 suffix is a Gradle Module
Metadata artifact, not a single jar — it publishes a manifest listing
every platform-specific variant (ktor-client-core-jvm,
ktor-client-core-iosarm64, ktor-client-core-js, ...) plus the rules for
which one matches which target. When Gradle resolves commonMain's
dependency graph for the jvm() target specifically, it reads that
manifest and substitutes in ktor-client-core-jvm transparently — this is
why the same implementation("io.ktor:ktor-client-core:2.3.12") line in
commonMain silently becomes a different physical jar depending on which
target's compilation is being resolved; there's no special Kotlin-side
logic for this at all, it's the Gradle dependency resolver following
metadata the library published.
expect fun httpClientEngine(): HttpClientEngineFactory<*> compiles for
commonMain as an unresolved reference at the bytecode level in exactly the
sense described in Level 3's multiplatform module — but here it's doing
real architectural work: HttpClient(httpClientEngine()) inside
GitHubClient is ordinary shared code that calls this expect function
like any other, and because Kotlin's type-checker only needs the shape
(HttpClientEngineFactory<*>) to typecheck commonMain, the actual engine
implementation (CIO's coroutine-based non-blocking I/O on JVM, Darwin's
wrapper around NSURLSession on iOS) never needs to be visible to, or
compilable by, the common source set at all — each platform's separate
compiler invocation links in its own concrete actual, so GitHubClient's
business logic is compiled exactly once as source but produces different
final machine code (JVM bytecode vs. an LLVM-compiled Kotlin/Native binary)
per target, each wired to a genuinely different underlying networking
implementation.
Cheat sheet¶
| Concern | Approach |
|---|---|
| Multiplatform artifact naming | Drop the -jvm/-android suffix in commonMain deps |
| Networking across platforms | expect fun engine(): HttpClientEngineFactory<*> |
| Storage across platforms | expect class Store wrapping each platform's native API |
| Apple-specific shared code | An intermediate iosMain source set above per-arch ones |
| Verify true portability | Actually add and build a second target, don't assume |
Exercise¶
Add a second expect/actual pair, expect fun platformName(): String,
with the JVM actual returning "JVM " + System.getProperty("os.name").
Use it inside commonMain to build a UserAgent string
("MyApp/1.0 (${platformName()})") that GitHubClient could attach as a
header on real requests, and print it from jvmMain's main().