Three years and a handful of production apps into Kotlin Multiplatform (KMP) at Iph Technologies, and I still see the same question in every "should we adopt KMP" thread: "but does it actually work in production, or is it another cross-platform promise that falls apart at scale?"
Short answer: it works, but not in the way the marketing pages frame it. Let me walk through the actual architecture decisions, the expect/actual gotchas, and the mistakes that cost my team real sprint time.
What You're Actually Sharing
KMP isn't one decision — it's three, and picking the wrong one for your team's maturity is the #1 reason migrations stall.
- Logic only, native UI → commonMain has business logic, SwiftUI + Jetpack Compose stay separate
- Logic + UI (Compose MP) → commonMain has logic AND UI, rendered natively per platform
- One feature module → commonMain has ONE isolated piece (auth, pricing, validation)
If you're migrating an existing native app (not greenfield), start with option 3. I'll explain why below.
Project Structure That Actually Scales
Here's the shared module layout I default to now, after getting it wrong twice:
shared/
├── build.gradle.kts
├── src/
│ ├── commonMain/kotlin/
│ │ ├── data/
│ │ │ ├── model/ # data classes, DTOs
│ │ │ ├── remote/ # Ktor client, API interfaces
│ │ │ └── local/ # SQLDelight queries
│ │ ├── domain/
│ │ │ ├── usecase/ # business rules live here
│ │ │ └── repository/ # interface only
│ │ └── di/ # Koin modules
│ ├── androidMain/kotlin/
│ │ └── platform/ # expect/actual impls
│ └── iosMain/kotlin/
│ └── platform/ # expect/actual impls
The rule I enforce on every review: if a class touches a platform API, it lives in androidMain/iosMain, not commonMain with a bunch of if (isAndroid) branches. That's what expect/actual is for.
expect/actual: The Pattern Everyone Gets Wrong Early
Here's a real example — getting the current timestamp in a way that's testable and platform-correct.
kotlin// commonMain
expect fun currentTimeMillis(): Long
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
actual fun currentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()
Simple, right? The mistake teams make is scaling this pattern to every platform difference instead of asking "does this actually need to be platform-specific, or am I just used to writing it that way?" Things like date formatting, currency rounding, and validation logic are almost always fully shareable — I've seen teams expect/actual code that had zero platform dependency, just because it "felt native" to split it.
A Real Shared Use Case
This is the kind of logic that should never live in two places. Take proration — the exact bug category that quietly wrecks retention when it drifts between platforms:
kotlin// commonMain/domain/usecase/CalculateProrationUseCase.kt
class CalculateProrationUseCase {
operator fun invoke(
planPriceCents: Long,
daysInCycle: Int,
daysRemaining: Int
): Long {
require(daysRemaining in 0..daysInCycle) {
"daysRemaining must be within cycle bounds"
}
val dailyRate = planPriceCents.toDouble() / daysInCycle
return (dailyRate * daysRemaining).toLong()
}
}
Android calls this directly. iOS calls it through the compiled Kotlin framework from Swift:
swiftlet useCase = CalculateProrationUseCase()
let proratedAmount = useCase.invoke(
planPriceCents: 2999,
daysInCycle: 30,
daysRemaining: 12
)
One implementation. One test suite. Zero chance of the two platforms disagreeing on a refund calculation.
Testing: Don't Split This Either
If you write your shared logic in commonMain but test it separately per platform, you've defeated the entire purpose. Use kotlin.test in commonTest so the same suite runs against both targets:
kotlin// commonTest
class CalculateProrationUseCaseTest {
private val useCase = CalculateProrationUseCase()
@Test
fun `full cycle remaining returns full price`() {
val result = useCase(2999, 30, 30)
assertEquals(2999, result)
}
@Test
fun `zero days remaining returns zero`() {
val result = useCase(2999, 30, 0)
assertEquals(0, result)
}
}
Run ./gradlew allTests and it executes against every registered target. This is the single highest-leverage habit I've adopted — it's caught platform-specific regressions before they ever reached QA.
KMP vs Flutter vs React Native — For Teams With Existing Native Apps
Most comparison posts frame this as greenfield-vs-greenfield. If you already have native apps, the calculus is different:
FactorKMPFlutterReact NativeCan you adopt without a rewrite?Yes — module by moduleNo — new codebaseNo — new codebaseUI renderingNative (or Compose MP)Custom engine (Skia)Native via bridge/JSIRollback cost if it failsLow — revert one moduleHigh — full Dart rewriteHigh — full JS rewriteNative API accessDirect, no wrapperPlatform channelsNative modulesLearning curve for iOS devsKotlin is new, but native tooling staysFull new stack (Dart)Full new stack (JS/TS)
If you're greenfield, this table looks different and Flutter/RN both become more competitive. But for teams with an existing native codebase and a churn problem tied to platform inconsistency specifically, incremental adoption is the deciding factor.
Mistakes That Cost Us Real Time
We shared UI before the team trusted the shared logic layer. We jumped to Compose Multiplatform in month one. Every UI bug got blamed on "the new framework" when the actual issue was our team not yet having conventions for shared state management. Lesson: prove the logic layer first, add shared UI later once the team has reps.
We didn't scope expect/actual boundaries up front. We let commonMain become a dumping ground, then had to retrofit platform boundaries mid-sprint when iOS-specific permission handling broke everything. Define your expect/actual seams during architecture review, not during a bug fix.
We tried to migrate the whole app before validating against our own dependencies. Legacy native SDKs (in our case, a payment processor with no Kotlin bindings) ate two extra sprints we hadn't budgeted. Pilot on a low-risk, high-impact module first — it surfaces integration landmines while the blast radius is small.
Is It Actually Production-Ready?
Yes — KMP is stable across Android, iOS, desktop, web, and server targets, and it's running in production at Netflix, McDonald's, Forbes, Google Workspace, and others. Compose Multiplatform is stable for Android/iOS/desktop; web is still beta, so weigh that if your target includes browser parity.
Where I'd Start If I Were You
Pick the one piece of logic most likely to already be quietly inconsistent between your Android and iOS codebases — proration, validation, auth token refresh — and port just that into a shared module behind your existing native UI. Ship it. Watch whether platform-specific bug reports on that feature drop. Then decide how far to expand.
That's the order of operations that's worked across every KMP migration I've run. Happy to go deeper on Koin/DI setup, SQLDelight for shared local storage, or CI config for multiplatform builds if there's interest — drop a comment.

Top comments (0)