Article · Blog
You chose Kotlin Multiplatform. And your ViewModel became a blind knot between two platforms.
Kotlin Multiplatform promises to share logic. What nobody warns you about is that sharing a ViewModel between Android and iOS changes what the ViewModel needs to be, and that breaks both sides. A decision I've seen cost weeks of refactoring.
What looked like an obvious decision
In a cross-platform project with KMP, you have a working shared domain layer. UseCases, repositories, models. Everything compiling on both targets. The natural temptation is to go one level up and share the ViewModel too.
It feels like a win. Less code. Less divergence between platforms. A single source of truth for screen state.
I've made that call before. And it was wrong.
Not because KMP is bad. But because ViewModel, on Android and on iOS, does not mean the same thing. And when you try to make one class serve two contexts with different lifecycles, you're not sharing code. You're creating a third thing that belongs to neither world.
The problem that shows up early but gets ignored
On Android, ViewModel survives configuration changes. It lives through screen rotation. It's anchored to the lifecycle of a Fragment or an Activity. The StateFlow it exposes is consumed by collectAsStateWithLifecycle() in Compose, which respects the active state of the UI.
On iOS, none of that exists. You'll expose that shared ViewModel as a SwiftUI ObservableObject, but the notification model is different. Coroutines have no direct equivalent. Flow needs a bridge. You'll end up using Kotlinx.coroutines with a scope that has to be managed manually because there's no ViewModelScope on iOS.
The first sign of trouble came when the shared ViewModel's scope kept running after the screen had been dismissed on iOS. No crash. No visible error. Just a network call running in the background with nobody listening. In production, that shows up as abnormal battery drain and duplicated API calls.
What the architecture hides from you
Most KMP articles show the layer that works well: data, domain, repositories. Pure business logic is genuinely portable. It has no lifecycle. No framework. It just transforms and validates.
The problem is in the layer above.
ViewModel is not business logic. It's the mediator between the domain and the UI. And mediation assumes you know both sides. When you try to write that mediator once for two radically different sides, you end up with a mediator that doesn't really know either of them.
At a fintech I worked at, the shared ViewModel started with twelve state properties. Three months later it had twenty-seven. Half of them only made sense on one platform. iOS ignored six fields. Android ignored four others. The shared class had become an accumulator of special cases from each platform.
There's a name for this: coupling by convenience. You didn't share because it made architectural sense. You shared because it was easier to write once.
What actually works in practice
The separation that proved most sustainable is to keep sharing up to the UseCase layer and stop there.
shared/
domain/
model/
usecase/
repository/ (interface)
data/
repository/ (implementation)
android/
presentation/
viewmodel/ ← native Android ViewModel
ios/
presentation/
viewmodel/ ← native SwiftUI ObservableObject
Each platform has its own ViewModel. It calls the shared UseCase, maps the result to the UI state that platform needs, and manages its own lifecycle with the native ecosystem's tools.
On Android, viewModelScope and StateFlow. On iOS, a Task managed inside the ObservableObject with @MainActor.
The shared UseCase returns a pure result: Result<DomainModel, DomainError>. Each platform maps that result to its own UiState.
Duplication? Yes. But it's the right kind of duplication.
The duplication that's worth it
There's a belief in the industry that duplication is always technically inferior. That DRY is a law, not a heuristic.
In practice, duplication between ViewModels on different platforms is rarely the real maintenance problem. The real problem is when business logic silently diverges. When Android calculates a balance one way and iOS calculates it another because each team touched its own copy over time.
Sharing the UseCase solves that problem. That's where divergence actually hurts.
Sharing the ViewModel solves nothing equivalent. It just adds friction to both teams.
On one project where two teams were working in parallel, the Android team needed to add exponential backoff retry logic to the payment ViewModel. The ViewModel was shared. Any change required iOS validation before it could be merged. The review cycle doubled. The feature slipped a sprint over a dependency that should never have existed.
The honest trade-off
Separating the ViewModels means more code. It means you'll write two mappings from DomainModel to UiState. If the domain changes, both ViewModels need to change.
That's real. It's not free.
But the alternative cost is worse: a ViewModel that belongs to neither platform, that carries baggage from both, that forces coordination between teams for any presentation change, and that hides lifecycle bugs that surface in production as vague symptoms.
The promise of KMP is to reduce divergence where divergence hurts. Business rule divergence hurts. Presentation divergence is expected. Different platforms have different UX, different navigation patterns, different state conventions.
Forcing convergence where the nature of the platform calls for divergence isn't efficiency. It's technical debt wearing the costume of architecture.
What I would do differently
I would define the sharing contract before writing a single line of code.
The question isn't "what can I share?" The right question is "where is divergence between platforms acceptable, and where is it dangerous?"
Business rules: dangerous to diverge. Share. Validation: dangerous to diverge. Share. Presentation mapping: divergence is expected. Don't share. Lifecycle management: divergence is inevitable. Don't even try to share.
When you answer that question before you set up the folder structure, the architecture becomes more obvious. And you stop sharing out of convenience and start sharing with intention.
What stays
KMP is one of the most serious cross-platform tools available today. The proposition is real: shared business logic, native platforms on each side.
But the word "shared" has become a dangerous mental shortcut. You hear "share the logic" and you start sharing everything that looks like logic.
ViewModel looks like logic. But it's structure. It's the mold that connects domain to platform. And molds are not universal.
The problem isn't KMP. It's the expectation that sharing more is always better.
Sometimes the healthiest code you can write is the code that consciously repeats a structure... because both platforms deserve versions of themselves, not a generic version that half-serves both of them.