Kotlin Multiplatform gets sold with a number: “share 90% of your code”. After a year of shipping AlgoMind to the App Store and Google Play from one repository, the number is roughly right and the framing is wrong. What matters is not the percentage but which code sits on each side of the line, because the two sides fail in completely different ways.

What shares, and why it stays shared

Business logic. Everything the product is lives in the shared module: the learning model, progress rules, what counts as a completed exercise, how a streak is computed. This is the code that must behave identically on both platforms, and a single implementation is the only reliable way to get that.

The data layer. One SQLDelight schema, one set of queries, one remote-first-then-local fallback strategy. The Android app and the iOS app never disagree about what is cached because they run the same code against the same schema. Platform differences are pushed down to a tiny driver factory.

Most of the UI. Compose Multiplatform renders the screens on both platforms. We were sceptical of this in year one and are not any more: the visualisation engine that plays an algorithm step by step is one Compose codebase, and it looks and behaves the same on a Pixel and an iPhone. The tell is how rarely a bug report says “only on iOS”.

Navigation and analytics events. Route definitions are typed and shared, so a deep link resolves to the same screen on both platforms, and analytics events are declared once so the funnels line up.

What never shares

Payments. StoreKit and Play Billing are different products with different rules, receipts and review expectations. We wrap them behind one interface (RevenueCat does most of the heavy lifting), but the platform code is real and stays platform code.

Deep links and universal links. The association files, entitlements and intent filters are platform plumbing. The handling of a resolved link is shared; getting the link to arrive is not.

Push, widgets, app-intents. Anything that touches the operating system’s surfaces outside your app is native work. Budget for it as native work.

Store submission. Two consoles, two review processes, two sets of screenshots and metadata. Sharing code does nothing for you here; sharing a release pipeline does.

Where it actually hurts

The pain is never in the shared logic. It is at the seams:

  • Foreign exceptions. An Objective-C exception thrown inside a Kotlin/Native frame is fatal unless you opt into wrapping it. You will find this out in production unless you set the compiler flag on day one.
  • Resource bundling. Fonts and images that the shared module needs have to end up inside the iOS bundle through a copy phase you did not write. When that phase silently disappears after a dependency reinstall, the app crashes on launch with a missing font.
  • Two toolchains, one version graph. The SQLDelight native libraries are compiled against a specific Kotlin version. Bump one, bump the other, or the iOS build breaks while Android is green.

None of these are reasons to avoid the approach. They are reasons to treat the seams as first-class engineering, with tests, scripts and documentation, rather than as glue you write once.

The rule we use now

Share what defines the product. Keep native what the platform owns. Never let a “shared” abstraction hide a payment, a link or a permission, because those are exactly the places a reviewer or a user will find the difference.

If you are deciding whether to bring an existing app to the other platform this way, that is the conversation to have first: not “how much can we share” but “which seams will we own”.