Mobile architecture
Kotlin Multiplatform in an existing app
In an app that already has two good native codebases, Kotlin Multiplatform is not a framework choice. It is a boundary: which rules become one implementation, who owns them on iOS, and what evidence stops the expansion.
14 min read
Most writing about Kotlin Multiplatform describes a new app, or a team that already decided. The harder case is the common one: two mature native apps, an iOS team in Swift, an Android team in Kotlin, and someone asking why every business rule is written twice. The question is not whether KMP works. It does. The question is which part of your app should become one implementation, and who will own it on the platform that did not choose Kotlin.
I have run 500+ technical interviews from the hiring side over 15 years in mobile. When a design discussion reaches cross-platform code, the weak answer names a framework. The strong answer names a boundary and the evidence that would move it.
My book Controlled Change puts the rule in two sentences: "Do not move code into the shared module merely because it compiles there. Share behavior that should remain semantically identical." This article applies it to an existing app: what to share, what to keep native, the Swift side, ownership, build and release cost, and when not to start.
- The short answer: share deterministic domain and data rules that already diverge; keep UI, lifecycle, navigation and platform capabilities native.
- The real cost: Swift interop, a build that crosses Gradle and Xcode, a versioned module inside your own repo, and an owner on iOS.
- The exit: a written stop condition before the first slice, and a native escape at every boundary.
Kotlin Multiplatform vs native is the wrong comparison
The facts have settled. Kotlin Multiplatform has been stable since Kotlin 1.9.20, and Google's Android documentation says KMP is officially supported for sharing business logic between Android and iOS, with Jetpack libraries such as Room, DataStore and ViewModel published for it. Compose Multiplatform for iOS reached stable in 1.8.0. None of that tells you what to share.
For an existing app, KMP is not a replacement for native code. It is a way to put some of the code both apps already contain into one Kotlin module, compiled for Android as usual and for iOS as a native framework. Controlled Change separates seven things a product can share, and the platform commitment rises down the list:
| What is shared | Examples | Fit in a mature native app |
|---|---|---|
| 1. Knowledge | Architecture rules, contracts, design tokens, test scenarios | Always worth it; needs no framework |
| 2. Generated artifacts | API clients, schemas, localization, assets | Often worth it; generators work without KMP |
| 3. Domain logic | Validation, pricing, conflict policy, state machines | The strongest KMP case |
| 4. Data and sync | Repositories, persistence abstractions, operation formats | Strong, if the platform I/O stays behind ports |
| 5. Presentation logic | Reducers, view models, navigation models | Only with an agreed behavior on both platforms |
| 6. UI rendering | Components, layout | Rarely, in an app with two established design languages |
| 7. Application shell | Lifecycle, notifications, background work | Keep native |
The book is explicit that this is not a ladder toward full sharing: "It is a map of increasing platform commitment." Most of the value for an existing app sits in rows 1 to 4. React Native and Flutter draw the line somewhere else, at the screen, and the questions that boundary raises (bridge cost, threads, over-the-air updates) are covered in React Native interviews happen at the boundary. KMP's line is lower, under the UI, which is why it fits apps that want to keep their native screens.
Measure the delay before you pick the module
A KMP proposal usually starts with "we write everything twice." That is a hypothesis, and it is cheap to test. The casebook in Controlled Change opens its cross-platform chapter with the fictional Atlas: sixty native engineers, mature iOS and Android apps, and a proposal to rewrite both in eighteen months. Before choosing anything, the team measures the share of roadmap work that is truly duplicate, parity delay by feature and cause, staffing, build time, crash and accessibility by journey, domain logic divergence, backend contract inconsistencies, release coordination, SDK burden, and the native code that cannot be abandoned.
In that fictional case, 30 percent of the delay comes from duplicate domain and data logic, 25 percent from backend and API inconsistency, 20 percent from release and process differences, and the rest from UI and platform work. The numbers are invented for the case. The shape is the lesson: a shared module can only remove the first slice. Contract fixes remove the second, and no framework removes the third. Atlas rejects the rewrite and chooses a portfolio: unify contracts, fixtures and analytics taxonomy; introduce KMP for selected pure domain rules and conflict policy; keep native UI and lifecycle; revisit after two production slices.
Write the alternatives down even when the answer looks obvious. The book's ADR chapter asks a cross-platform proposal to record whether native, KMP, React Native, Flutter, web content and server-driven UI were considered, with the team's skills and the escape-hatch costs, so the next debate starts from the analysis instead of repeating it.
Draw the boundary: a stable shared kernel
The shared module that survives in an existing app is small and boring on purpose. Controlled Change lists what a stable shared kernel has: deterministic pure logic or carefully scoped concurrency; value-oriented inputs and outputs; no hidden global environment; explicit time, randomness and I/O ports; portable tests; a limited dependency surface; and release compatibility with both hosts.
Equally useful is the list of what Atlas keeps out of the first slices: database engines and migrations, the background scheduler, camera, maps and payments, the navigation shell, accessibility semantics, the on-device model runtime, and UI state whose platform behavior is not yet aligned. Each host composes those natively and hands the shared code what it needs through a narrow port, not a universal context object.
A kernel in that spirit, written as plain common Kotlin: time arrives through a port, inputs and outputs are values, and the outcome is a closed set. The second function is the seam for the migration described below.
// commonMain: plain Kotlin, no platform imports. Time comes in through a port,
// inputs and outputs are values, and the result is a closed set of outcomes.
fun interface Clock { fun nowEpochMillis(): Long }
data class TripDraft(
val startEpochMillis: Long,
val endEpochMillis: Long,
val travellers: Int,
)
sealed interface TripCheck {
data object Valid : TripCheck
data class Invalid(val rule: String) : TripCheck
}
class TripValidator(private val clock: Clock, private val maxTravellers: Int) {
fun check(draft: TripDraft): TripCheck = when {
draft.travellers !in 1..maxTravellers -> TripCheck.Invalid("travellers")
draft.startEpochMillis < clock.nowEpochMillis() -> TripCheck.Invalid("start-in-past")
draft.endEpochMillis <= draft.startEpochMillis -> TripCheck.Invalid("end-before-start")
else -> TripCheck.Valid
}
}
// The brownfield seam: the native validator stays the answer, the shared one
// runs in shadow, and a mismatch is reported by rule, never shown to the user.
fun shadowCompare(
native: TripCheck,
shared: TripCheck,
report: (nativeRule: String, sharedRule: String) -> Unit,
): TripCheck {
if (native != shared) report(native.ruleName(), shared.ruleName())
return native
}
private fun TripCheck.ruleName(): String = when (this) {
TripCheck.Valid -> "valid"
is TripCheck.Invalid -> rule
}Nothing in it knows about iOS, Android, threads or storage, which is what makes it testable once and trustworthy on both platforms. The same boundary discipline applies to any module split, covered in when to modularize a mobile app: the shared kernel is a module with two consumers in two languages.
Data and sync logic is the next candidate, and the riskiest. Operation formats and conflict policy share well; the storage engine and the scheduler that runs sync do not. The decision about what the sync layer should be in the first place is in how to choose a mobile data sync solution.
Swift interop is the iOS team's daily experience
Android engineers consume the shared module as ordinary Kotlin. iOS engineers consume whatever the compiler generates for Swift, and that surface decides whether adoption survives. The Kotlin documentation is direct about the default path: Kotlin/Native provides indirect interoperability with Swift through Objective-C. In practice that means:
- Suspend functions appear as functions with completion handlers. Calling them from Swift as
asyncis possible from Swift 5.5, and the documentation calls that highly experimental with certain limitations. - Exceptions reach Swift as
NSErroronly when the Kotlin function declares them with@Throws. Other Kotlin exceptions that reach Swift terminate the program. - Generics can only be defined on classes, not on interfaces or functions, and some type information is lost in translation.
- Enums used in a Swift
switchneed a default case.
The direct route is Swift export, which exports suspend functions as Swift async functions and flows as AsyncSequence. The same page says it is in Alpha and incomplete, that breaking changes are expected, and that it works only with direct integration. Check its status against the Kotlin version you would ship, and plan as if the Objective-C path is the one you will live with.
Atlas makes the design rule explicit: the shared module accepts clocks, randomness, storage and network through narrow ports, and it does not force Swift callers to consume awkward Kotlin framework details without adapters. That turns into three habits. Keep the exported surface small and value-shaped. Put a thin Swift facade in front of it, owned by the iOS team, so the rest of the iOS app never imports the Kotlin framework directly. And have an iOS engineer review every change to the exported API, the way you would review a public SDK.
Who owns the shared module on iOS
This is where KMP adoptions in existing apps usually stall, and it is not a technical problem. The Android team wrote the Kotlin. The iOS team ships the app where it crashes. If the iOS on-call engineer cannot read the stack, build the module or change it, the shared code has an author but no owner. Controlled Change says it about shared screens, and it holds for shared logic: "A shared screen maintained by a team that cannot diagnose native crashes or performance is not really owned."
The casebook's organization model splits the work in a way that holds up:
- a platform team owns the runtime, the bridge, templates, CI, upgrades and incident tooling;
- the stream team owns the shared feature end to end, including the native adapters on both platforms;
- iOS and Android specialists stay embedded or reachable;
- an enabling team supports the first migrations and transfers knowledge;
- adoption is opt-in through a decision matrix, not a percentage target.
Two questions to settle before the first merge. Who on the iOS side can build the shared module locally and step into it when a test fails? And who is paged when a shared rule misbehaves on one platform only? If the answer to both is "the Android team," the iOS team has been given a dependency, not a capability. The book's Staff perspective puts the incentive in the right place: the Staff engineer "funds native capability rather than celebrating a code-sharing percentage."
Build and release cost you will pay every week
A shared module changes your build and your release even if it is ten files. The book's line is the one to put in the proposal: "Cross-platform code creates a versioning system even inside one repository." The decisions it lists: shared module version and host compatibility, binary frameworks or source integration, generated bindings, staged rollout by platform, crash and performance segmentation, rollback when only one platform is affected, support for older host applications, and feature flags that span shared and native behavior.
Integration. The Kotlin documentation describes several ways to bring the iOS framework into Xcode: direct integration through a script in the Xcode project, a local Swift package or CocoaPods podspec in a mono repository, or a remote XCFramework distributed through Swift Package Manager or CocoaPods. A mono repository with direct integration keeps one change in one pull request; a published XCFramework lets the iOS app pin a version and makes the shared module a dependency with its own release.
CI. The iOS framework is built with Xcode, so the pipeline needs a macOS lane that builds it and compiles the iOS app against it on every change to shared code. Run the common tests on an iOS target as well as the JVM, so a change that breaks only the Swift surface or only the Kotlin/Native build fails the pull request that caused it, not the next iOS release.
Feedback loop and size. Measure the iOS edit, build and test loop before and after, and put the framework's contribution to app size on the same budget as everything else. The book's benchmark list for this decision includes startup, memory, app size, the developer edit/test/build loop, and crash diagnosis with symbol mapping: "The architecture decision includes operational tooling, not only runtime performance."
Release. Do not let shared code synchronize the two releases by accident. Keep platform-specific cohorts, independent flags, a native fallback at the feature boundary and a compatibility matrix between the shared module and each host. A bug in shared logic now exists in two apps with two review queues, so containment may have to come from a flag or the backend while store updates propagate. The same old-version reasoning as in how long to support old mobile app versions applies: every binary in the field carries its own copy of the rules.
Migrate one slice at a time, with a shadow
The book's brownfield sequence is short: establish shared contracts and test fixtures; extract one pure domain rule; integrate through explicit platform ports; compare delivery and field evidence; expand to one cohesive feature or data capability; preserve native entry and exit points; stop if bridge, ownership or quality cost exceeds leverage; and delete duplicated paths only after platform parity and rollback confidence. Its reason for not rewriting: "A rewrite commits to assumptions before evidence exists. Incremental adoption turns the framework into a reversible decision."
The seam for each slice is a facade on each host. In the casebook's Trip Validation example, the old native validator stays the default, the shared validator runs in shadow, mismatches are classified by rule and locale, no private input leaves the device, high-impact mismatches block rollout, the shared validator becomes the default by platform and cohort, and the old implementation is deleted after one release window. That is what shadowCompare in the kernel above is for: the native answer is still the one the user sees until the mismatch report is quiet.
The team proves integration, build, debugging and ownership on that slice before sharing anything larger. Whether the whole effort should be a migration at all, rather than a rewrite or a refactor of what each app has, is the question in rewrite or refactor a mobile app.
When not to use Kotlin Multiplatform
Controlled Change gives the stop conditions for expansion. Pause if:
- native bridge work consumes the expected savings;
- accessibility or platform quality repeatedly lags;
- app size or startup exceeds budget;
- build and debugging become slower;
- releases become coupled without product value;
- the team cannot own both shared and native failure;
- domain logic remains volatile and the shared abstraction freezes discovery;
- measured delivery parity does not improve.
Turned around, those are the reasons not to start. Do not adopt KMP in an existing app when the measured delay is mostly UI, platform work or backend inconsistency; when the rules you would share change every sprint and nobody yet agrees on them; when the candidate code is camera, maps, payments, background execution or accessibility; or when no one on the iOS side will own Kotlin. Sharing the UI of a mature app with two established design languages is the move most likely to turn a capability into a fight.
The outcome to aim for may be smaller than the proposal. The casebook says so plainly: "A bounded, successful shared kernel is a valid outcome." The book's ADR for Atlas records the same discipline: KMP for selected domain policy, native UI on both platforms, no shared-code percentage target, and a revisit after three production features that compares lead time, defects, build time, iOS ergonomics and the ability to revert.
What I listen for when a team proposes Kotlin Multiplatform
| Topic | Mid-level | Senior | Staff |
|---|---|---|---|
| Why | "We write everything twice." | Domain rules diverge between the apps. | The measured share of delay that is duplicate logic, and what the rest is caused by. |
| What to share | "The business logic." | Validation and models first, UI native. | A named first slice, an excluded list, ports for time, storage and network, and a stop condition. |
| iOS side | "It compiles to a framework." | Objective-C export limits; maybe Swift export. | A designed Swift surface behind an iOS-owned facade, reviewed by iOS, with the interop status checked for the shipped Kotlin version. |
| Ownership | "Android owns it." | A shared module team. | Platform team for toolchain and CI, stream team end to end, an iOS engineer who can debug it, and an on-call answer for one-platform failures. |
| Release | "Same as now." | Version the module. | Compatibility matrix, independent cohorts and flags, native fallback, and no coupled releases without product value. |
The staff column is not more enthusiastic about Kotlin. It is more specific about where the sharing stops and who carries it on the platform that did not ask for it.
Questions engineers ask about Kotlin Multiplatform in an existing app
Kotlin Multiplatform vs native: which should an existing app choose?
For an app that already has mature native iOS and Android code, it is rarely a choice between the two. The common production shape keeps native UI and lifecycle on each platform and moves selected domain rules, contract models and data logic into a shared Kotlin module. The decision is which of those pieces to share, measured against the delay they actually cause today.
What should be shared first in Kotlin Multiplatform?
A pure logic leaf with no UI or platform dependencies that already diverges between the two apps: validation rules, pricing or domain calculations, conflict policy, or API models and their parsing. It proves the toolchain, the Swift surface, CI and ownership on something where rollback is cheap and the payoff is visible.
How does Swift call Kotlin Multiplatform code?
By default through Objective-C. Kotlin/Native generates an Objective-C framework, so suspend functions arrive as completion handlers, generics exist only on classes, and a Kotlin exception becomes a Swift error only when the function is annotated with @Throws. Swift export, which targets Swift directly and maps suspend functions to async and flows to AsyncSequence, is in Alpha in the current Kotlin documentation. Either way, design the shared module's Swift surface as an API and have an iOS engineer review it.
When should you not use Kotlin Multiplatform?
When the measured delay is not duplicate logic, when the domain rules are still changing weekly, when the iOS team cannot own or debug the shared module, when the candidate code is mostly platform behavior such as camera, maps, payments, background work or accessibility, or when the build, app size and release coupling costs exceed the duplicate work you remove. A small shared kernel that works is a valid end state.
