DEV Community

Pavel Pristalov
Pavel Pristalov

Posted on Originally published at Medium

The Best Architecture for KMP Apps, and When Not to Use It (Part 1)

KMP: where to draw the boundary. Choosing a stack and architecture for a product that will grow across Android, iOS, desktop, web, and backend

In KMP and CMP app architecture, the main question is not how much code you can share. It is where you should stop.

I will show two approaches that work. The first is an architecture for a large mobile product: a shared Kotlin layer for business logic and a native UI on every platform. The second is the startup path, where Compose Multiplatform helps you quickly launch mobile products, web and desktop applications, and even a backend built on shared data models.

Let us discuss when you should and should not use the "best" architecture.


This is the first, theoretical article in a two-part series. In the second part, we will move on to practice, examine real implementation problems, and look at the additional benefits this architecture gives us.

I am Pavel Pristalov, a Staff Engineer with 20 years of experience in IT. I have designed and developed mobile services for small startups and multinational companies.

TL;DR: the template repository for the architecture described here is github.com/pristalovpavel/kmp-clean-template.

Introduction

I agree that the title is somewhat clickbaity. But the architecture I am going to describe really is the best one I have encountered in 15 years of mobile development. It gives you the skeleton of a large application, one to which you can add feature after feature simply and predictably.

Its main advantages are:

  • Simple. It is based on KISS and YAGNI. This reduces onboarding time, code review time, and the number of possible mistakes and misunderstandings within the team.
  • Powerful. It allows you to implement every native capability of each platform. No bridges, reflection, questionable workarounds, or performance compromises.
  • Cost-effective. It reuses as much code as possible while compiling it with each platform's own toolchain: JVM bytecode for Android, a native binary for iOS, and JS or Wasm for the web. This reduces development and testing time several times over and makes problems easier to locate and fix.
  • Extensible. We can add support for an entirely new platform fairly easily.
  • Practical. It solves the basic application problems we face every day: unified error handling, pagination, offline mode and cache updates, dependency injection, authentication and token refresh, shared analytics logic, background synchronization, network retries, logging, and remote feature management.

The proposed architecture: overview and principles

What should we use as the foundation of our architecture? Clean Architecture, of course. It is a relatively simple, understandable, and well-proven approach to building even complex applications.

Clean Architecture in KMP: Data and Domain live in shared, while Presentation remains native

I will not explain Clean Architecture itself, its principles, or its advantages. They are covered in detail in the classic articles by Robert C. Martin and Fernando Cejas. Google's official Guide to app architecture recommends the same broad layers: data, domain, and UI. Here, we will focus on the practical details of applying this architecture to KMP projects.

The shared layer and ViewModel

Let us start with the most important rule: limit the KMP shared module to the data and domain layers. Do not move ViewModels into it, let alone the UI.

Yes, I advise against using CMP as the foundation of cross-platform applications that you expect to develop and maintain for years. CMP is an excellent solution for Android. It can and should be used for web and desktop when you want to reuse an existing UI there. But iOS differs significantly, and in a large project an attempt to share ViewModels and UI will almost certainly create a pile of problems.

Here are some of them:

  • Jetpack ViewModel was designed around the Android lifecycle. On iOS, you have to provide a ViewModelStoreOwner manually, which is foreign to an iOS developer. In the iOS world, a ViewModel is a snapshot of state for a view, not a lifecycle-management mechanism.
  • When StateFlow crosses the Objective-C interop boundary, its generic type is lost, and SwiftUI cannot observe it directly. You need wrappers such as CommonStateFlow, an adapter such as KotlinViewAdapter, and a manual binding between a CoroutineScope and the view lifecycle. In practice, "sharing a ViewModel" means writing a glue layer that does not exist in a native approach.
  • Nested ViewModels are not supported by the Jetpack library.

Here is the minimum glue required to share a ViewModel with native SwiftUI. The code is based on Tristan Ferre's article. First, a wrapper in shared code, because Objective-C exposes StateFlow to Swift without its generic type:

class CommonStateFlow<T>(private val flow: StateFlow<T>) : StateFlow<T> by flow {
    fun startCollect(
        coroutineScope: CoroutineScope,
        onEach: (T) -> Unit,
    ): CommonCancellable {
        val job = coroutineScope.launch { flow.collect { onEach(it) } }
        return CommonCancellable { job.cancel() }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then a generic adapter on the Swift side. It follows the view lifecycle and exposes the state through @Published:

final class KotlinViewAdapter<State: AnyObject, VM>: ObservableObject {
    @Published private(set) var state: State
    let viewModel: VM
    private var subscription: CommonCancellable?
    private let scope = CoroutineScopeFactory().create()

    init(viewModel: VM, stateFlow: CommonStateFlow<State>) {
        self.viewModel = viewModel
        self.state = stateFlow.value as! State
        self.subscription = stateFlow.startCollect(coroutineScope: scope) { [weak self] newState in
            self?.state = newState as! State
        }
    }

    deinit {
        subscription?.cancel()   // Manually cancel both the subscription
        scope.cancel()           // and the Kotlin scope, or they will leak.
    }
}

// Only after all that can SwiftUI use it:
// @StateObject var adapter = KotlinViewAdapter(viewModel: ..., stateFlow: ...)
Enter fullscreen mode Exit fullscreen mode

And this is only the skeleton. Real glue code grows factories for scopes, reverse event propagation, and one-off side-effect handling.

Sharing the ViewModel splits Presentation in two; exposing a UseCase keeps it native

In this article, Touchlab gives what I consider the right recommendation: share ViewModels when using Compose Multiplatform; with a native iOS UI or a team with a strong iOS culture, keep the Presentation layer platform-specific.

There are, of course, exceptions. Backend-driven UI is a natural fit for CMP, as is any sufficiently isolated feature that does not require completely native UI and UX.

Why else do we not share ViewModels?

I waited a long time for the ability to reuse a ViewModel across platforms. The idea appears perfectly logical: a ViewModel contains the business logic of the Presentation layer. If it is business logic, then by our own criteria for good architecture, it should be written once for every platform. But the devil is in the details.

Sharing a ViewModel artificially splits the Presentation layer in two simply because doing so appears convenient. It also forces us to unify the architecture at the topmost layer, which is not always desirable.

Android and iOS differ in both capabilities and UX. To build a genuinely complex application that uses the full range of native platform features, you would constantly have to work around these differences: empty actual declarations, interface implementations, and platform-specific code pulled into common code. Just keep the entire Presentation layer native.

Navigation remains native as well: NavigationStack on iOS and Navigation Compose on Android. Consistency between platforms is provided not by a shared navigator but by shared data and domain layers. Screens call the same UseCases and receive the same results.

What about iOS?

This approach quietly removes the main obstacle to adopting KMP code: resistance from iOS developers. They can build the UI layer however they want, without being tied to an Android ViewModel architecture. They simply retrieve data from a UseCase in the shared layer instead of Alamofire, while getting all the data-handling capabilities of that layer. In my experience, this significantly reduces both development time and the time needed to adopt KMP.

On iOS, data comes from a shared UseCase while SwiftUI and its ViewModel remain native

Moreover, because the shared layer contains nothing from the Android framework, iOS developers can write its code themselves. Let us see how difficult that code really is. It contains data models, API interfaces, a SQL database, repositories, repository implementations, and UseCases. In simple cases, these are straightforward entities that are easy to write or generate from an existing example. More difficult cases exist, but even then an iOS developer can understand and maintain the code because it is based on language-independent principles rather than Android-specific concepts.

That is why a project may need only three technical onboarding documents: an architecture description, a description of the file structure, and a document titled "How to create a new feature or screen." Simpler is better.

Testability

There is another consequence of keeping only data and domain in shared: this code is easy to test. It contains no Android framework, UIKit, or lifecycle coupling, only pure Kotlin. The application's business logic can therefore be covered by regular unit tests that run on the JVM in seconds, without an emulator, simulator, or instrumented tests.

In fully native implementations, the most valuable logic, including data access, error handling, caching, and authentication, is tested separately on each platform. Here, it lives in one place and is covered once for all of them. Write a test for a UseCase, and you exercise the logic that will run on Android, iOS, desktop, and the web.

The UI-specific behavior is tested with the platform's native tools, such as XCTest and Compose UI tests, just as it would be in any native application.

The shared business logic is tested once; each platform tests only its own UI

I will show what this looks like in code in the second, practical part.

The bonuses

When data and business logic live in a multiplatform shared module from day one, adding another target is relatively inexpensive. We get three useful bonuses.

A single shared core can support Android, iOS, desktop, backend, and web targets

Desktop. An Android application built with Compose gives us a desktop version almost for free. Some work remains: selecting the right libraries, packaging the application, and adapting layouts for larger screens. Modern Android applications should already avoid assumptions about orientation and screen size because designers and developers must account for tablets and foldable devices.

Creating installers for macOS, Windows, and Linux takes some additional time. But this is still far cheaper than building a separate desktop application while making the product available on all three platforms.

A backend built on shared models. The shared module already contains DTOs annotated with @Serializable. Nothing prevents us from adding them as a regular dependency to a Ktor server, which is just another JVM target. That is exactly what the template repository does: it contains a mock or proxy backend that responds with the same types the application parses. The API contract is shared and cannot silently diverge. Change a model, and the client and server are rebuilt together, so a mismatch is found during compilation instead of in production.

More importantly, such a backend unblocks mobile development and testing while the real backend is still under construction. The teams need only agree on the API contract.

Web. There are two paths, and their maturity differs significantly.

The first is Kotlin/JS: shared logic as a library for a web team. The same module compiles into a JavaScript library with TypeScript declarations. We mark the public API with @JsExport, and the web team connects our UseCases to React, Vue, or Angular as an ordinary npm package. It is exactly the same pattern as on iOS: a platform-owned UI and shared business logic.

JetBrains presents this as the primary Kotlin/JS scenario and maintains an official example, kmp-logic-sharing-simple-example, with Android, iOS, and React clients. This pattern also works in production: since 2019, Quizlet has used a shared answer-checking engine across web and mobile applications with a combined install count in the hundreds of millions (case study).

To be fair, the developer experience is still worse here than at the iOS boundary. Parts of the export functionality remain experimental, and some Kotlin idioms are lost: List and Flow require explicit facades, while exporting suspend functions and Long has only recently appeared behind experimental flags. The resulting library can also be several times larger than handwritten JavaScript. Doist reported approximately 41 KB gzipped versus 10 KB for its JavaScript implementation.

The second path is Kotlin/Wasm with Compose Multiplatform: the entire shared application runs in the browser, with Compose rendering to a canvas through WebAssembly. This is already practical for admin panels and internal tools. For public-facing products, canvas rendering still creates issues with SEO and accessibility, and the initial bundle remains heavy.

Despite the progress in Kotlin for Web, there is no free lunch and no "SKIE for Web" that hides all of these differences. A small web team can consume shared logic through Kotlin/JS. A quick internal tool can be built with CMP and Wasm while reusing Ktor, SQLDelight, Koin, and other multiplatform libraries.

The template repository implements the first path: a Kotlin/JS web application on top of the shared UseCases, plus an @JsExport facade with TypeScript declarations for an external web team.

Why not Flutter or React Native?

When you expect to actively develop and maintain a complex product for years, do not use PWA, Flutter, or React Native. Use KMP and CMP. If the project grows and needs a more rigorous architecture, KMP gives you a gradual migration path.

Adding a native feature to a KMP application is almost painless: create a SwiftUI screen and add the corresponding shared logic. There are no platform channels. Adding a native feature to a Flutter application means platform channels such as MethodChannel or Pigeon, separate Android and iOS implementations, and embedding native views through PlatformView.

Flutter and React Native tend toward a rewrite, while KMP can evolve toward native UI incrementally

In my experience, after a year and a half or two years, many actively developed Flutter and React Native projects arrive with the request, "Rewrite it natively." Most of the application then has to be rewritten from scratch, which is expensive and painful. KMP grows through evolution and gradual legacy refactoring. There is no stack replacement and no need to maintain two unrelated stacks, only controlled and predictable refactoring.

The opposite situation is also common: we already have two native applications and want to share their logic. The same approach works from that direction. Do not rewrite anything. Choose one new feature, move its data and domain code into a shared module, and connect it to both applications. Measure development speed and the number of defects. If the results are good, move features into shared one by one and refactor legacy code only when necessary. If the experiment fails, you can stop at any time.

When not to use this architecture

So we have a great architecture. When should we not use it?

Case 1: a startup without clear requirements

If you do not have detailed implementation requirements, the team is very small, and the budget is extremely limited, do not spend resources on layers, extensive documentation, and boilerplate. Write the code quickly, for every platform at once, with KMP and CMP.

Case 2: there is nothing meaningful to share

The second case is an application with almost nothing to move into the shared layer: Android and iOS widgets with little network traffic, for example, or small utilities. Online-only applications without a cache can also fall into this category. In these cases, entire data and domain layers may become unnecessary boilerplate.

Case 3: the application will barely evolve

The third case is an application that will require very little future development and maintenance. Consider a mobile application for a small restaurant chain with a fixed feature set: book a table, view the menu, create a loyalty card, and display it when paying. Such an application may have an offline mode and nontrivial data processing, but if no new features are planned, an elaborate architecture may not be worth the cost.

Project Share logic? Share UI? Recommendation
Large product with long-term maintenance Yes: data + domain No Clean Architecture in KMP: shared core + native UI
Startup hypothesis, small team Yes Yes, entirely KMP + CMP without elaborate layers: speed matters more than structure
Isolated feature or BDUI inside a large product Yes Yes, selectively CMP screens embedded in a native application
Widgets, small utilities, online-only apps without a cache Almost nothing to share No Pure native development in the platform language
A mostly finished application with a fixed scope Yes Depends on the team KMP + CMP: inexpensive now, with a growth path if the plans change

Summary

If I had to compress the entire article into a single thought, it would be this: KMP is not "write once, run everywhere." It is about sharing exactly what makes sense to share.

  • In a large product, use Clean Architecture in KMP: shared data and domain, native UI.
  • In a small project or startup experiment, use CMP throughout to launch quickly on every platform.
  • If the application is mostly UI, without complex data, offline behavior, or networking, use the platform's native stack.

There is no chasm between these approaches. There is a staircase. As the project grows, add the missing layers and gradually remove CMP from iOS without rewriting everything from scratch. The question is not whether to use KMP. It is where your project is on that staircase today. Look at your product and either start moving or deliberately stay where you are. Both choices are valid when they are made honestly.

KMP and CMP can be introduced at any stage of a mobile product's evolution

In Part 2, we will examine the architecture's core in code: UseCase contracts, SKIE, error handling, dependency injection, modularity, and testing.

Template repository: github.com/pristalovpavel/kmp-clean-template.

References

Architecture and approach:

Why the Presentation layer stays native:

Web targets and sharing logic with the browser:

Top comments (0)