A Kotlin Multiplatform App Is More Than a Shared Module
It is possible to have a technically elegant shared module and still ship a poor app.
That sounds obvious, but cross-platform discussions often stop at the architecture diagram: shared models, shared networking, shared ViewModel, then a box for each native UI. The diagram does not tell us whether the app works offline, whether the iOS bridge is pleasant to maintain, whether a release can be reproduced, or whether users can find the product.
I have built a KMP application where the shared Kotlin module owned models, service calls, repositories, ViewModel state and business rules. Android used Jetpack Compose and iOS used SwiftUI. That division worked because the shared code represented behavior that needed to stay consistent, while the interfaces could remain platform-specific.
The next lesson was that code sharing is only one part of delivery.
Treat data flow as a product decision
For many apps, the local data layer should be more than a cache behind the network. If screens always depend directly on a remote response, intermittent connectivity can turn ordinary navigation into a sequence of loading states and failures.
A local-first design gives the UI a stable source to observe. The repository coordinates local reads and remote synchronization; the UI renders the state it receives. This is not a requirement to build an elaborate offline-sync engine for every app. It is a prompt to decide explicitly where the source of truth lives and what the user sees when the network is slow or unavailable.
In a KMP project, that decision is especially useful because the data contract and business rules can be shared and tested once. Platform-specific storage details can remain behind an interface when the platforms need different implementations.
Keep the architecture proportional to the product
Shared code can make it tempting to introduce a module for every noun in the domain. That creates build configuration, dependency boundaries and navigation costs before the product has earned them.
I prefer to split modules when a boundary protects a real constraint: independent ownership, a reusable capability, a build-time benefit, or a dependency direction that would otherwise be violated. A Domain layer is useful when the business rules are complex or reused. It is not useful merely because a diagram looks more complete with another box.
The current Kotlin Multiplatform guidance supports more than one layout. A single shared module can be enough; separate shared-logic and shared-UI modules can help when some applications use native UI. The structure should reflect the product’s sharing decisions, not dictate them. Kotlin’s recommended project structure makes this distinction explicit.
Test the seams, not only the shared functions
Common business logic is a natural place for fast unit tests. But the riskiest failures can live at the boundaries: Swift consuming Kotlin state, platform implementations satisfying shared interfaces, persistence migrations, and build pipelines producing the frameworks each app needs.
So I would test at least four things early:
- Shared rules and state transitions in common tests.
- Platform implementations against the same contract where practical.
- iOS integration from Xcode, not only from the Kotlin build.
- A release-shaped build in CI, including dependency and framework integration.
The point is not to duplicate every test on every target. It is to find integration friction before the shared API has spread through the app.
Optimize for a small team’s ability to ship
KMP can reduce duplicated implementation, but it does not remove the need for product validation, store metadata, crash monitoring, support and release work. For a small team, the best architecture is the one that lets you make a change, test it on both platforms, and get it into users’ hands without a fragile sequence of manual steps.
That is why I would judge a KMP app by more than its shared-code percentage. Does the shared boundary reduce maintenance? Does each platform still feel appropriate? Can the team debug production issues? Can the app be released repeatedly?
If those answers are yes, the architecture is doing its job. If not, adding another shared layer will not fix the product pipeline.