Pedro Sousa
← Articles

Article · Blog

Kotlin Multiplatform is not Flutter. And that confusion is costing entire architectures.

Most teams adopt Kotlin Multiplatform with the same mindset they'd use for Flutter, and that's when things start to fall apart. Sharing logic across platforms is different from sharing a product. That distinction rarely shows up early enough.

Kotlin Multiplatform is not Flutter. And that confusion is costing entire architectures.

When I worked on Flutter projects in a fintech context, the promise was simple: one codebase, two platforms, half the effort. In practice, what showed up was a UI that felt native on neither platform, a shared state that created invisible coupling, and a platform layer so thin that any native feature required a third-party plugin with uncertain maintenance.

I'm not saying Flutter is bad. I'm saying Flutter solves a specific problem, and part of the teams adopting Kotlin Multiplatform are trying to solve that same problem with the wrong tool, without realizing these are different problems.

What most people assume about KMP

The most common narrative I hear is: "We'll use Kotlin Multiplatform to write the code once and run it on iOS and Android."

Sounds reasonable. But that framing already carries the mistake.

KMP was not built for you to have a multiplatform app. It was built for you to have shared business logic with independent native UIs. The distinction seems subtle. The architectural impact is not.

When a team treats KMP as "Flutter with native UI," the first thing that happens is the creation of a shared module that starts absorbing everything. UseCase, Repository, ViewModel, Model, sometimes even presentation logic. Within a few months, shared has become the new monolith. iOS and Android depend on it for everything, and any change there requires coordination between two teams with different release cycles.

I call this the Infinite Shared Trap. The module exists to reduce duplication. It ends up centralizing dependency.

The problem that shows up late

The cycle is predictable.

First, the team celebrates: "Look, the authentication logic is in shared, it works the same on both sides." Second, someone on the Android team needs a slightly different behavior in the login flow because of a regulation or a platform-specific product requirement. Third, the negotiation begins: add a parameter? Create an interface? Just duplicate it?

In financial apps, this problem shows up faster because critical flows have platform nuances that are not design accidents. They're intentional. Face ID on iOS works differently from biometrics on Android. Background behavior on iOS has restrictions that Android doesn't have in the same way. Local data encryption has distinct APIs on each side.

If the shared module tries to abstract away these differences, you end up creating interfaces that pretend the platforms are the same when they're not. And every abstraction that lies about reality will make you pay for that lie later.

What actually works in practice

The separation that makes sense is more granular than "shared" and "not shared."

What belongs in the shared module: pure domain entities, business rules without side effects, serialization and network data parsing, validation logic. Things that genuinely have no difference between platforms because they live at a level of abstraction above them.

What does not belong: ViewModels, state management, anything that needs to react to lifecycle, anything that touches native APIs. Those elements belong in the platform-specific layers, written in the native language, with native tools.

On Android, that means Kotlin with Jetpack ViewModel, StateFlow, Coroutines handling lifecycle correctly. On iOS, Swift with Combine or async/await, structs and classes that respect Swift's memory model, integration with SwiftUI or UIKit without translation layers.

The result is not "less shared code." It's shared code that actually survives without needing a workaround for every new feature.

The honest trade-off

This approach has a real cost.

You will write ViewModels twice. The loading state for a screen will be implemented in Kotlin and in Swift. Part of the team will feel like this is wasteful.

It's not. It's the price of respecting the platform.

The real waste shows up when you have a shared ViewModel that needs expect/actual for every platform-specific behavior, and the list of actual implementations grows to the point where the "shared" code is just a skeleton with diverging implementations underneath. At that point, you have the complexity of both platforms plus the complexity of the abstraction layer. With none of the original benefits.

Share what is generic. Write natively what is specific. The rule is simple. The discipline to maintain it is not.

What I would do differently today

When I join a project that is considering KMP, the first question I ask is not about architecture. It's about the product: where do iOS and Android actually behave the same, and where don't they?

If the answer is "in almost everything," KMP can be a solid choice for domain logic, and the UI will be native on each platform.

If the answer is "it depends on the feature," the project will need active discipline to prevent the shared module from growing beyond what it should. That means code reviews focused specifically on this, and someone with the responsibility to say "this doesn't go in shared" when necessary.

What I would not do: leave that decision implicit. Teams without a clear definition of what belongs in the shared module make local decisions that make sense individually and create a systemic problem over months.

In a context where I worked with modular architecture for multiple teams and products, the pattern that worked was defining the shared module's boundaries before writing the first line, revisiting that definition every quarter, and treating violations as technical debt with an explicit cost.

Not glamorous. But it's what separates a shared module that lasts from one that becomes a maintenance problem nobody wants to touch.

The underlying confusion

In the end, the problem is not technical. It's conceptual.

Flutter shares a product: you write UI once and deliver it to both platforms. KMP shares logic: you write business rules once and the platforms consume them in distinct ways.

These are different philosophies with different trade-offs. Treating KMP as Flutter "with more UI work" is like treating a REST API as a database "that lives on the network." Technically not wrong, but you'll make bad decisions because the mental model is wrong.

What worries me is not that teams choose KMP. It's that they choose it without clarity about what they're getting. And when the shared module starts bloating, the common response is "we'll refactor later"... which rarely happens.

The architecture you accept in month one is the one you'll inherit in month twelve.