Kotlin Multiplatform
Sharing Kotlin across targets — expect/actual declarations, common and platform source sets, the klib format, iOS interop through the Objective-C header and the alpha Swift export, the Native memory model, Kotlin/Wasm, the KMP library ecosystem, and adoption strategy.
18 questions
JuniorTheoryVery commonWhat do expect and actual declarations do in a Kotlin Multiplatform module?
What do expect and actual declarations do in a Kotlin Multiplatform module?
expect declares an API in commonMain with no body, and each platform source set supplies a matching actual. A target whose actual is missing fails to compile, so the seam is checked by the compiler, not at runtime.
Common mistakes
- ✗Thinking a missing
actualis a runtime failure rather than a compile error - ✗Believing
expectneeds a default body incommonMain - ✗Treating
expect/actualas inheritance instead of a per-target declaration match
Follow-up questions
- →Can an
actualwiden the visibility or change the default arguments of itsexpect? - →When would you inject an interface instead of adding another
expectdeclaration?
JuniorTheoryVery commonHow do commonMain and platform source sets differ in a Kotlin Multiplatform module?
How do commonMain and platform source sets differ in a Kotlin Multiplatform module?
commonMain compiles for every target, so it sees only the common standard library, not any platform SDK. androidMain and iosMain each compile for one target and may use its full API. Common code reaches them through expect/actual.
Common mistakes
- ✗Expecting JVM APIs like
java.io.Fileto be visible fromcommonMain - ✗Treating source sets as folders Gradle merges rather than separate compilations
- ✗Forgetting that
commonMainmust compile for every declared target, not just the easy one
Follow-up questions
- →What is an intermediate source set such as
iosMainshared by two iOS targets for? - →How does adding a new target change what
commonMainis allowed to call?
JuniorTheoryCommonWhy can the multiplatform HTTP client Ktor be called straight from commonMain?
Why can the multiplatform HTTP client Ktor be called straight from commonMain?
Ktor publishes a common API plus a per-target engine — OkHttp on Android, Darwin on iOS — declared by its platform source set. The API is a multiplatform library, so commonMain compiles against it and each target links its engine.
Common mistakes
- ✗Thinking
Ktorreimplements the network stack instead of delegating to a platform engine - ✗Believing an iOS target needs a hand-written
expectHTTP bridge - ✗Forgetting that the engine dependency belongs in the platform source set, not
commonMain
Follow-up questions
- →What breaks if you forget to declare an engine dependency for one of your targets?
- →Which other libraries would you expect to publish the same common-API-plus-engine shape?
JuniorTheoryCommonWhat does Kotlin Multiplatform actually share between Android and iOS?
What does Kotlin Multiplatform actually share between Android and iOS?
KMP shares Kotlin logic — networking, persistence, validation — compiling it to a JVM library for Android and to a native framework for iOS. UI stays platform-native by default; sharing it is a separate opt-in through Compose Multiplatform.
Common mistakes
- ✗Believing KMP shares the UI by default, when only logic is shared
- ✗Thinking the shared module runs in an interpreter instead of compiling to a native framework
- ✗Assuming iOS receives transpiled Swift rather than a Kotlin/Native framework
Follow-up questions
- →Which artifact does the Kotlin/Native compiler hand to an Xcode build?
- →What does opting into Compose Multiplatform add on top of shared logic?
MiddleTheoryCommonWhy can kotlinx.serialization encode a data class in commonMain without reflection?
Why can kotlinx.serialization encode a data class in commonMain without reflection?
It is a compiler plugin, not a reflection library. At compile time it generates a KSerializer for each @Serializable class from its structure, so the serializer is ordinary code in each target's output — no JVM reflection needed.
Common mistakes
- ✗Assuming
kotlin-reflectis available in common code across all targets - ✗Thinking the
@Serializableannotation is read at runtime rather than by a compiler plugin - ✗Expecting to hand-write an
actualserializer per platform
Follow-up questions
- →What does the plugin do when a
@Serializableclass holds a type it cannot generate a serializer for? - →Why does the format — JSON, protobuf, CBOR — stay a runtime choice rather than a compile-time one?
MiddleTheoryOccasionalWhy does a call to freeze() no longer compile in a modern Kotlin/Native module?
Why does a call to freeze() no longer compile in a modern Kotlin/Native module?
freeze() is still declared but deprecated at error level, so the call will not compile. It belonged to the retired legacy memory manager: modern Kotlin/Native uses a tracing garbage collector and shares objects across threads unfrozen.
Common mistakes
- ✗Saying
freeze()was removed, when it is still declared but deprecated at error level - ✗Believing Kotlin/Native still requires a cross-thread object to be frozen
- ✗Assuming the modern runtime refuses to share mutable state between threads
Follow-up questions
- →If nothing is frozen any more, what now protects shared mutable state on Kotlin/Native?
- →What is the practical difference between a deprecation at warning level and at error level?
MiddleTheoryOccasionalWhat is a klib, and why can a multiplatform library not just publish a JVM jar?
What is a klib, and why can a multiplatform library not just publish a JVM jar?
A klib holds serialized Kotlin declarations plus an intermediate representation, not JVM bytecode. A jar carries bytecode alone, which Kotlin/Native and Kotlin/JS cannot use, so a common dependency ships as a klib lowered per target.
Common mistakes
- ✗Assuming JVM bytecode is a portable format every Kotlin target can consume
- ✗Thinking a
klibis a bag of prebuilt per-target binaries rather than declarations plus IR - ✗Believing the Gradle plugin can convert a jar into a
klibon demand
Follow-up questions
- →What does the compiler still have to do with a
klibbefore an iOS binary exists? - →Why is a
klibtied to the compiler version that produced it?
MiddleDebuggingOccasionalFix a shared Kotlin API the iOS team cannot use comfortably from Swift
Fix a shared Kotlin API the iOS team cannot use comfortably from Swift
Kotlin/Native exports via an Objective-C header, which has no default arguments and no exhaustive sums — so Swift demands every parameter and still wants a default. Add explicit overloads and expose a discriminator Swift can switch over.
Common mistakes
- ✗Expecting Kotlin default arguments to survive into the Objective-C header
- ✗Assuming a
sealed classstays exhaustive for a Swiftswitch - ✗Reaching for
@JvmOverloads, which is a JVM annotation and does nothing on Kotlin/Native
Follow-up questions
- →What happens to a
suspendfunction when it crosses into the Objective-C header? - →How would you keep the ergonomic Kotlin API for Android and still export a Swift-friendly one?
SeniorDesignOccasionalYou inherit two mature apps — a 250k-line Android app and a 180k-line iOS app — that implement the same product. Leadership wants Kotlin Multiplatform, and an architect has proposed a six-month feature freeze during which both apps are rewritten against one shared module. You believe an incremental path is safer and want to make that case with a concrete plan. Describe how you would introduce Kotlin Multiplatform without freezing delivery: what you would move into the shared module first and why that slice, how each app would consume the shared module, how both teams keep shipping during the migration, and what evidence would tell you to stop expanding the shared module rather than keep going.
You inherit two mature apps — a 250k-line Android app and a 180k-line iOS app — that implement the same product. Leadership wants Kotlin Multiplatform, and an architect has proposed a six-month feature freeze during which both apps are rewritten against one shared module. You believe an incremental path is safer and want to make that case with a concrete plan. Describe how you would introduce Kotlin Multiplatform without freezing delivery: what you would move into the shared module first and why that slice, how each app would consume the shared module, how both teams keep shipping during the migration, and what evidence would tell you to stop expanding the shared module rather than keep going.
No freeze: the shared module is just another dependency, so both apps keep shipping. Move one platform-free leaf slice first — domain models plus a pure algorithm like pricing. Expand while it deletes duplicates; stop when seams cost more.
Common mistakes
- ✗Accepting a delivery freeze when the shared module can be consumed as an ordinary dependency
- ✗Starting with the platform-bound edges instead of a dependency-light leaf slice
- ✗Treating full coverage of the codebase as the goal rather than the duplicates actually deleted
Follow-up questions
- →How would you publish the shared module so the iOS build does not need a Kotlin toolchain?
- →What would you do if the first shared slice makes the iOS build measurably slower?
SeniorDebuggingOccasionalShared state mutated from two coroutines corrupts on iOS but never on Android
Shared state mutated from two coroutines corrupts on iOS but never on Android
The modern memory manager lets Kotlin/Native threads share mutable objects, so nothing complains: this is an ordinary data race, just as on the JVM. Freezing is gone and is not the fix — guard the state from common code with a Mutex.
Common mistakes
- ✗Reaching for
freeze(), which is deprecated at error level and was never a synchronisation primitive - ✗Believing Kotlin/Native still prevents two threads from mutating the same object
- ✗Fixing it on the iOS side when the unguarded state lives in
commonMainand Android races too
Follow-up questions
- →Why does the same code appear to work on Android far more often than on iOS?
- →When would you prefer confinement to a single dispatcher over taking a
Mutex?
SeniorTheoryOccasionalHow does Swift export differ from the Objective-C header path, and which one ships today?
How does Swift export differ from the Objective-C header path, and which one ships today?
Kotlin/Native ships iOS through an Objective-C framework Swift consumes, losing default arguments, exhaustive sums and generic precision. Swift export emits Swift bindings directly and keeps far more, but it is Alpha, not shippable.
Common mistakes
- ✗Believing Swift export is already the default or Stable path
- ✗Assuming the Objective-C header preserves default arguments and sealed exhaustiveness
- ✗Treating the choice as cosmetic when it changes how much of the Kotlin model survives
Follow-up questions
- →Which Kotlin constructs are the most expensive casualties of the Objective-C header?
- →What would have to be true of Swift export before you put it on a release branch?
SeniorTheoryOccasionalHow does Kotlin Multiplatform differ from the cross-platform frameworks Flutter and React Native?
How does Kotlin Multiplatform differ from the cross-platform frameworks Flutter and React Native?
KMP shares logic and compiles to a native binary per target — no added runtime, VM or bridge — so each platform keeps its own UI and full API access. Flutter ships its own engine and widgets; React Native drives native views from JS.
Common mistakes
- ✗Thinking KMP drives native views over a bridge at run time, as React Native does
- ✗Believing KMP hands you shared screens the way Flutter and React Native do
- ✗Assuming Flutter's widgets are the platform's own controls rather than its engine's
Follow-up questions
- →What does opting into Compose Multiplatform change about this comparison?
- →When is Flutter's shared UI the right trade for a product?
MiddleTheoryRareWhat is Kotlin/Wasm's current maturity, and where would you reach for it today?
What is Kotlin/Wasm's current maturity, and where would you reach for it today?
Kotlin/Wasm compiles to WebAssembly and is Beta, not Stable: usable, but its surface can shift between releases, so budget for re-verifying upgrades. It targets browsers, where Compose Multiplatform renders a shared UI, and WASI runtimes.
Common mistakes
- ✗Calling Kotlin/Wasm Stable and treating it as a drop-in replacement for Kotlin/JS
- ✗Assuming WebAssembly cannot drive a browser UI at all
- ✗Planning a long-lived product on a Beta target without budgeting for upgrade churn
Follow-up questions
- →What does Beta status practically oblige you to budget for over a year of releases?
- →How does adding a Wasm target change what
commonMainis allowed to depend on?
SeniorDesignRareA team has spent a year moving code into commonMain, and it has worked: the domain models, the validation rules, the pricing engine, the HTTP layer and the on-device cache all live there now, and every move deleted a duplicate implementation. Encouraged by that, they propose moving two more things in. The first is the code that asks the user for location permission. The second is the job that syncs orders in the background while the app is not in the foreground. A senior engineer objects that this is exactly where the pattern stops paying off, but cannot state the rule, and the team reads the objection as conservatism. Give them the test. Say what belongs in commonMain and what does not, explain what makes these two proposals different in kind from the moves that already succeeded, and describe the seam you would use for the parts that must stay platform-specific — including when you would use no seam at all and simply leave the code native in each app.
A team has spent a year moving code into commonMain, and it has worked: the domain models, the validation rules, the pricing engine, the HTTP layer and the on-device cache all live there now, and every move deleted a duplicate implementation. Encouraged by that, they propose moving two more things in. The first is the code that asks the user for location permission. The second is the job that syncs orders in the background while the app is not in the foreground. A senior engineer objects that this is exactly where the pattern stops paying off, but cannot state the rule, and the team reads the objection as conservatism. Give them the test. Say what belongs in commonMain and what does not, explain what makes these two proposals different in kind from the moves that already succeeded, and describe the seam you would use for the parts that must stay platform-specific — including when you would use no seam at all and simply leave the code native in each app.
commonMain takes what is platform-independent: models, validation, business rules, networking and persistence via KMP libraries. Permissions, background work and UI stay native, behind expect/actual only if common code needs them.
Common mistakes
- ✗Treating an
expect/actualseam as free, so every platform API is dragged into common code - ✗Testing by language — can it be written in Kotlin — instead of by platform-independence
- ✗Assuming networking and persistence must stay native because they end in platform APIs
Follow-up questions
- →Where would a view model shared by both apps sit under this rule?
- →What evidence would make you pull something back out of
commonMain?
SeniorDesignRareYour shared Kotlin module has been in production for a year and the iOS team is unhappy. Every call site needs all arguments spelled out, a closed result hierarchy still forces a default branch, and the generated names read like Objective-C. An engineer proposes turning on Swift export, which would emit Swift bindings directly and preserve most of the Kotlin model. The next release train leaves in three weeks and carries a payments change. Decide whether to enable Swift export now, and defend the decision: what its current maturity implies for a release branch, what you would do instead for this train, and under what conditions you would revisit it.
Your shared Kotlin module has been in production for a year and the iOS team is unhappy. Every call site needs all arguments spelled out, a closed result hierarchy still forces a default branch, and the generated names read like Objective-C. An engineer proposes turning on Swift export, which would emit Swift bindings directly and preserve most of the Kotlin model. The next release train leaves in three weeks and carries a payments change. Decide whether to enable Swift export now, and defend the decision: what its current maturity implies for a release branch, what you would do instead for this train, and under what conditions you would revisit it.
Do not put an Alpha feature on a release branch carrying payments. Prototype Swift export on a branch, but ship this train on the Objective-C header and fix the ergonomics where it is cheapest: a Swift-facing facade in the shared module.
Common mistakes
- ✗Treating Alpha as a label about tooling rather than about the generated output itself
- ✗Assuming a feature can be scoped to one module to contain its maturity risk
- ✗Dismissing the interop friction as cosmetic when it costs the iOS team on every call site
Follow-up questions
- →What would a prototype branch have to show before you would schedule the switch?
- →How would you keep the Swift-facing facade from leaking into the Android call sites?