6 min read

Kotlin Multiplatform: When NOT to Share

Shared code carries a coordination tax — two build systems, two release trains, two review queues. The useful test isn't “can we share this” but “does the cost of divergence exceed the cost of coordination”, and for UI and platform integration it usually doesn't.

Kotlin MultiplatformAndroidiOSArchitectureKMP
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Sharing code is not free; it converts a local change into a cross-platform change. Share where the two platforms are required to agree — domain rules, validation, networking and serialization, analytics schemas — because divergence there is a bug. Don't share UI, platform integration, or anything a product team churns weekly, because there the coordination tax exceeds the duplication it removes. And don't start with the module that hurts most; start with the one that's stable and well tested.

The tax nobody puts in the proposal

KMP proposals are written in terms of lines of code removed. The cost shows up somewhere else.

Once a module is shared:

  • A one-line change needs both platforms' CI to pass.
  • It needs review from someone who understands both consumers.
  • Its release cadence is the slower of the two — or you invest in versioning the shared module, which is its own ongoing cost.
  • An Android engineer's refactor can now break an iOS build they can't run locally.
  • Debugging crosses a language boundary, and the Kotlin/Native side of an iOS stack trace is not where most iOS engineers want to be at 3am.

None of that is an argument against KMP. It's the price, and the decision is only sound if you price it. Crucially, the tax scales with change rate, not with size. A large, stable module is cheap to share. A small module that a product team edits twice a week is expensive.

The test that actually works

Is "these two platforms must behave identically" a requirement, or do they merely look similar today?

If it's a requirement, divergence is a bug, and sharing converts a class of bug into a compile-time guarantee. If it's a coincidence, you're paying coordination costs to enforce a similarity nobody asked for — and you'll pay again the first time product wants them to differ.

Share this

Domain models and business rules. The one with the clearest return. A discount calculation, a subscription state machine, an eligibility rule — implemented twice today, with two subtly different rounding behaviours, one of which is wrong. This is exactly the case where "must agree" is a requirement.

// commonMain — no platform opinion, high correctness value
fun Cart.total(): Money = items
    .sumOf { it.price * it.quantity }
    .applyDiscount(activeDiscount)
    .roundToCurrency(currency)

Validation. A valid IBAN, a password policy, a well-formed postcode. Stable, testable, identical by definition.

Networking and serialization. Ktor plus kotlinx.serialization gives you one set of DTOs, one endpoint definition, one error-mapping layer. Churny, but the churn is driven by the API, so both platforms change together anyway — the coordination is happening regardless, and sharing at least makes it explicit.

Analytics event schemas. Underrated. "Shipped the event on Android, forgot iOS" is a data bug you discover a quarter later when someone asks why the funnel doesn't add up.

Repository and caching logic, if your data model is genuinely shared. SQLDelight makes this real.

Don't share this

UI. The point isn't that Compose Multiplatform doesn't work — it does. The point is that platform UI conventions are a product decision. iOS users expect an edge-swipe back gesture, a bottom sheet that behaves like a UIKit one, native scroll physics. Sharing UI makes matching those conventions a negotiation instead of a default. Some products genuinely don't care — an internal tool, a content-heavy app with a strong custom design language. Most consumer apps do, and "it looks slightly non-native" is not a bug you can prioritise away.

Platform integration. Permissions, background execution, notifications, deep links, biometrics. expect/actual here produces an abstraction over two things that were never the same:

expect class BackgroundScheduler {
    fun schedule(work: Work)     // WorkManager? BGTaskScheduler?
}

Their constraints differ, their failure modes differ, their debugging differs. The shared interface has to be either the intersection (too weak to be useful) or the union (leaky in both directions). Write these twice, natively, and share the domain work they invoke.

Anything a product team changes weekly. Feature UI logic, experiment wiring, campaign rules. The tax scales with change rate — this is the worst place to pay it.

Anything one platform ships first, always. If a feature reaches iOS a month before Android by design, shared code makes that harder, not easier.

Don't start with the hardest module

The instinct is to share the module that hurts most — usually the one where the two platforms have already diverged and caused a bug. That module is painful precisely because it's complex, poorly tested, and platform-entangled. Making it your first KMP project means learning the toolchain and untangling the mess at the same time, and if it goes badly the conclusion the org draws is "KMP doesn't work here".

Start with something stable, well tested, and boring. Validation logic is the classic first module: a week of work, an obvious win, and it teaches the team the build setup, the iOS consumption story, and the CI changes without risk.

The iOS side is the real adoption question

The technical decision is only half of it. The other half:

  • Are iOS engineers willing to consume a Kotlin binary they can't easily step into?
  • Who fixes a Kotlin/Native memory issue at 3am?
  • Does your iOS CI build the shared module, or consume a published artifact? (Publish. Building it in every iOS CI run makes every Android change an iOS build failure risk.)
  • Is there at least one iOS engineer with ownership, not just consumption?

A KMP rollout with no iOS ownership isn't a shared module. It's an Android team shipping a dependency to a team that didn't ask for it, and it will be resented long before it's removed.

The rule

Share code where "these must agree" is a requirement, not where the two happen to look alike. The question was never how much you can share — it's how much divergence would actually cost you, and whether that's more than the coordination you're about to buy.

Keep reading