KMP Shared Module in Practice
Ktor, kotlinx.serialization, SQLDelight, Koin, source sets, Gradle, multiplatform tests
- 01
The shared Ktor client compiles fine but throws on the very first request on iOS only — what does
HttpClientneed per target?EasyHttpClientis only an API surface; the actual HTTP work is done by a per-platform engine, and that engine artifact has to be declared in each platform source set.// shared/build.gradle.kts — core in common, an engine in every leaf kotlin { sourceSets { commonMain.dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentNegotiation) … - 02
commonMainrefuses to compile againstjava.util.Dateandjava.io.File— what replaces the JDK types you reach for out of habit?EasycommonMainsees only the Kotlin standard library, so everyjava.*type is simply invisible there, and the gaps are filled by small official multiplatform libraries.// None of this resolves in commonMain — it is all java.* // val now = System.currentTimeMillis() // val stamp = SimpleDateFormat("yyyy-MM-dd").format(Date()) // val text = File(path).readText() // synchronized(lock) { counter++ } … - 03
A gateway returns an HTML 502 and the shared client blows up inside
body<User>()— how is JSON wired into Ktor, and where does that failure belong?Mediumbody<T>()only deserializes; deciding that a response is an error is a separate job, and Ktor does not do it for you by default.// commonMain — one Json, one place where transport failures become domain errors val appJson = Json { ignoreUnknownKeys = true // the backend will add fields explicitNulls = false } … - 04
A feed mixes post, ad and banner objects under one
typekey — how do you decode that with kotlinx.serialization in shared code?MediumModel it as a
@Serializable sealedhierarchy: the compiler plugin registers the subclasses for you, andJsonwrites and reads a class discriminator to tell them apart.// Closed polymorphism: sealed + @Serializable, subclasses registered by the plugin @Serializable @JsonClassDiscriminator("kind") // the default key is "type" sealed interface FeedItem { @Serializable @SerialName("post") data class Post(val id: Long, val body: String) : FeedItem @Serializable @SerialName("ad") data class Ad(val id: Long, val campaign: String) : FeedItem … - 05
A SQLDelight query that is instant in a JVM test drops frames on device — what does each platform's driver do, and where does that query run?
MediumSQLDelight generates plain blocking code, the driver is the only per-platform piece, and every generated call executes on whatever thread you happened to call it from.
// commonMain — SQLDelight 2.x lives under app.cash.sqldelight // (it was com.squareup.sqldelight in 1.x, and the whole package moved) expect class DriverFactory { fun create(): SqlDriver } … - 06
You added a column to a shipped
.sqfile and existing installs crash on launch — how do SQLDelight migrations actually work?MediumA
.sqfile describes the schema a fresh install gets; upgrading an existing database is a separate.sqmfile, so editing only the.sqleaves old installs with old columns and new queries that select from them.// src/commonMain/sqldelight/com/example/db/Player.sq — the DESTINATION schema // CREATE TABLE player ( // id INTEGER NOT NULL PRIMARY KEY, // name TEXT NOT NULL, // team_id INTEGER -- added in v2 // ); … - 07
A list backed by a SQLDelight query never refreshes after a write — what does
asFlow()listen to, and what makes it re-emit?MediumasFlow()registers aQuery.Listenerfor the tables the query reads, and SQLDelight notifies those listeners only when a write goes through the generated API on the same driver.class PlayerRepo( private val db: AppDatabase, private val driver: SqlDriver, private val io: CoroutineDispatcher = Dispatchers.IO, ) { // asFlow() emits Query objects; mapToList runs them on the context you hand it. … - 08
Your Koin modules live in
commonMain— how does the iOS app start that graph, and how does Swift pull a repository out of it?MediumSwift never talks to Koin directly: you start the container from a top-level Kotlin function the iOS app calls once, and expose typed accessors, because
get<T>()isinline reifiedand reified generics do not survive the Objective-C bridge.// commonMain — one graph, platform pieces behind an expect val sharedModule = module { single { httpClient() } single { UserApi(get()) } single<FeedRepo> { FeedRepoImpl(get(), get()) } factory { FeedViewModel(get()) } … - 09
Your shared module wires everything in one object of factory functions and someone wants to add Koin — what does a container actually buy you?
MediumA container buys you lifetimes, one override point, and a lookup the iOS side can call without spelling out a twelve-argument constructor — until you need those, hand-written factories are a legitimate DI story and cheaper to read.
// Manual wiring — one class, compiler-checked, nothing reflective class SharedGraph( driver: SqlDriver, // the platform hands in what only it can build engine: HttpClientEngine ) { private val db by lazy { AppDatabase(driver) } … - 10
Where does the auth token live in a KMP app — in multiplatform-settings, or behind your own interface over Keychain and the Keystore?
Mediummultiplatform-settingsis the right default for preferences and the wrong home for a token, because the guarantees a secret needs cannot be expressed byputString(key, value).// commonMain — the secret gets its own interface, not a Settings key interface TokenStore { suspend fun read(): String? suspend fun write(token: String) suspend fun clear() } … - 11
Your
expectdeclarations have grown past a dozen and every new target means another folder ofactuals — when is expect/actual the wrong tool?Mediumexpect/actualis a compile-time switch with no seam: it binds one name to exactly one implementation per target, so anything you might want to fake, configure or swap belongs behind an interface instead.// ❌ Everything as expect/actual — untestable, and a new target means new files everywhere // commonMain expect class AnalyticsTracker() { fun track(event: String, params: Map<String, String>) } // androidMain: actual class AnalyticsTracker { ... Firebase ... } … - 12
You add a
watchosArm64target and code that compiled yesterday inappleMainstops building — where do source sets likeiosMainandappleMaincome from?MediumThe Kotlin Gradle plugin generates them from the targets you declare, through the default hierarchy template — so the target list defines the tree, and adding one silently changes what the shared parents have to compile for.
kotlin { androidTarget() iosArm64() iosSimulatorArm64() watchosArm64() // <- adding this widens appleMain for everyone … - 13
How do you prove the shared repository retries a 503 three times with backoff, without the test sitting there for seven real seconds?
HardSwap the network for Ktor's
MockEngineincommonTestand make sure the backoffdelayruns on the test scheduler — thenrunTestskips it in virtual time and the virtual clock itself becomes the assertion.// commonMain class FeedRepository(private val client: HttpClient, private val baseDelayMs: Long = 500) { suspend fun load(attempts: Int = 3): List<Post> { repeat(attempts - 1) { i -> runCatching { client.get("/feed").body<List<Post>>() }.onSuccess { return it } … - 14
A crash inside shared Kotlin is a readable stack trace on Android and a wall of hex in the iOS report — what has to be in place on each side?
HardOn Android the shared code is ordinary JVM bytecode and Crashlytics deobfuscates it from your R8 mapping file; on iOS it is native machine code, so the report is only as readable as the dSYM you uploaded — and by default the Kotlin exception itself never reaches the reporter.
// commonMain interface CrashReporter { fun log(message: String) fun setKey(key: String, value: String) fun record(error: Throwable) // non-fatal } … - 15
Two other teams want to depend on your shared module — what does
./gradlew publishactually put in the repository, and what does a breaking change cost each consumer?HardA KMP publish is not one artifact but one module per target plus a root module whose Gradle metadata points at them — and none of it helps an Xcode consumer, who needs an XCFramework published alongside.
plugins { kotlin("multiplatform") id("maven-publish") } kotlin { … - 16
A library you added builds fine for Android and fails resolution for
iosArm64— how do you catch a JVM-only dependency early, and what are your options then?HardGradle resolves a separate compile classpath per target, so a JVM-only dependency fails at resolution for the native target — "no matching variant", listing the attributes it wanted — long before anything is compiled or linked.
// ❌ A JVM-only SDK in commonMain — fails resolving the iOS classpath, not at link time // commonMain.dependencies { implementation("com.vendor:analytics:4.1.0") } // // > Could not resolve com.vendor:analytics:4.1.0. // > No matching variant was found. Consumer attributes: // > org.jetbrains.kotlin.platform.type = native, org.jetbrains.kotlin.native.target = ios_arm64 …