KMP Shared Module in Practice
Irbisa · cheatsheetSeptember 13, 2026

KMP Shared Module in Practice

Ktor, kotlinx.serialization, SQLDelight, Koin, source sets, Gradle, multiplatform tests

Middle Developer16 itemscompressed for a skim
  1. 01

    The shared Ktor client compiles fine but throws on the very first request on iOS only — what does HttpClient need per target?

    Easy

    HttpClient is 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)
    …
  2. 02

    commonMain refuses to compile against java.util.Date and java.io.File — what replaces the JDK types you reach for out of habit?

    Easy

    commonMain sees only the Kotlin standard library, so every java.* 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++ }
    …
  3. 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?

    Medium

    body<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
    }
    …
  4. 04

    A feed mixes post, ad and banner objects under one type key — how do you decode that with kotlinx.serialization in shared code?

    Medium

    Model it as a @Serializable sealed hierarchy: the compiler plugin registers the subclasses for you, and Json writes 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
    …
  5. 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?

    Medium

    SQLDelight 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
    }
    …
  6. 06

    You added a column to a shipped .sq file and existing installs crash on launch — how do SQLDelight migrations actually work?

    Medium

    A .sq file describes the schema a fresh install gets; upgrading an existing database is a separate .sqm file, so editing only the .sq leaves 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
    // );
    …
  7. 07

    A list backed by a SQLDelight query never refreshes after a write — what does asFlow() listen to, and what makes it re-emit?

    Medium

    asFlow() registers a Query.Listener for 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.
    …
  8. 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?

    Medium

    Swift 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>() is inline reified and 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()) }
    …
  9. 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?

    Medium

    A 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. 10

    Where does the auth token live in a KMP app — in multiplatform-settings, or behind your own interface over Keychain and the Keystore?

    Medium

    multiplatform-settings is the right default for preferences and the wrong home for a token, because the guarantees a secret needs cannot be expressed by putString(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. 11

    Your expect declarations have grown past a dozen and every new target means another folder of actuals — when is expect/actual the wrong tool?

    Medium

    expect/actual is 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. 12

    You add a watchosArm64 target and code that compiled yesterday in appleMain stops building — where do source sets like iosMain and appleMain come from?

    Medium

    The 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. 13

    How do you prove the shared repository retries a 503 three times with backoff, without the test sitting there for seven real seconds?

    Hard

    Swap the network for Ktor's MockEngine in commonTest and make sure the backoff delay runs on the test scheduler — then runTest skips 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. 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?

    Hard

    On 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. 15

    Two other teams want to depend on your shared module — what does ./gradlew publish actually put in the repository, and what does a breaking change cost each consumer?

    Hard

    A 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. 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?

    Hard

    Gradle 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
    …