Tomas Parizek5 min

Kotlin Multiplatform in Production: What Worked, What Didn’t

EngineeringiOSJan 6, 2026

EngineeringiOS

/

Jan 6, 2026

A 3D geometric shape composed of purple and pink blocks and circles, set against a dark background with code snippets.
Tomas ParizekiOS Engineer

Share this article

TL;DR

We shipped our first production app powered by Kotlin Multiplatform, with shared business logic and a fully native SwiftUI interface. What worked best was a clear architectural split, a monorepo setup and a ViewModel-Store pattern that made KMP feel natural on iOS. What didn’t work as well were sealed classes, concurrency assumptions across platforms and debugging across runtimes. Our takeaway is simple. KMP works best when used deliberately, with shared logic where it matters and native UI where it counts.

Architecture: Shared Logic, Native UI

We recently delivered our first large Kotlin Multiplatform (KMP) project, where shared Kotlin code drives the core of a production iOS app built with SwiftUI. The Android app will follow later and reuse the same shared layer.

Architecturally, we drew a clear line between what belongs in KMP and what stays native.

In the shared layer, we placed repositories, services, models, use cases and ViewModels. This is where we encoded business rules, data flow and screen-level logic. We also moved user-facing resources such as strings, images and configuration values into KMP so they could be reused across platforms.

On iOS, we kept the UI fully native using SwiftUI. Views, navigation, platform-specific components and platform integrations live in Swift. Also, the native layer owns lifecycle concerns and the overall iOS experience. This approach lets us deliver a UI that feels native to the platform while still reusing as much logic as possible.

The main benefit of this split is consistency. Behavior and rules are defined once, in one place. When the Android app joins, it will consume the same shared layer. At the same time, each platform keeps full freedom over UI, navigation and platform-specific polish.


Bridging KMP to iOS

Bridging KMP into the iOS app took more than generating a framework. The biggest challenge was making shared ViewModels and state feel natural in SwiftUI.

Our solution was simple. Each KMP ViewModel is paired with a small Swift “Store” object.  The ViewModel lives in Kotlin and handles business logic and state transitions. The Store lives in Swift and owns the ViewModel lifecycle, subscribes to state changes and exposes observable properties to SwiftUI. This setup gave us predictable state propagation while keeping SwiftUI code clean and idiomatic.

We applied the same thinking to shared resources. KMP manages the underlying resource system, but iOS engineers work with small Swift wrappers that provide typed access to strings and images. This hides interop details and makes shared resources feel like regular Swift constants.

The pattern introduces some duplication since every ViewModel has a corresponding Store. In practice, it made debugging, testing and day-to-day development far more straightforward than relying on generated types alone.

Repository Setup and the Monorepo Decision

We started with separate repositories for the iOS app and the KMP shared module. On paper, this separation looked clean. In practice, it created friction. Every small change in shared logic required a release-and-consume cycle. iOS was often blocked waiting for new KMP versions. Debugging issues across both layers meant juggling local builds and version mismatches.

Mid-project, we merged both into a single monorepo. From that point on, shared and native changes lived in the same pull requests, and teams developed against source instead of binaries. The trade-offs, a larger repository and a slightly heavier Gradle setup, were manageable compared to the drop in coordination overhead.

For products where shared logic and native UI evolve together, we now treat a monorepo as the default starting point.

What Didn’t Work Well

Not everything translated cleanly from Kotlin to Swift, and those rough edges shaped our guidelines for future KMP work.

Sealed classes were the first feature we moved away from. They work well in Kotlin, but the generated interfaces in Swift were awkward to use and extend. Pattern matching across the boundary was clumsy enough that we stopped exposing sealed hierarchies in public APIs and opted for simpler data structures that bridge better.

Memory and object ownership also required extra care. Data classes are surfaced as Objective-C-compatible types, which makes retain cycles with closures and reactive code easier to introduce. We had to be explicit about disposal and ownership at the Swift and Kotlin boundary.

Concurrency was another area where assumptions broke down. Swift’s structured concurrency model does not align perfectly with Kotlin coroutines once you cross the Objective-C bridge. We had to be deliberate about where flows were collected, on which threads side effects were executed, and how state changes were synchronized back into SwiftUI. This was solvable, but it required discipline.

Debugging across both runtimes added overhead as well. Stack traces became harder to interpret when errors crossed the boundary. Rebuild times increased whenever we modified shared code because both the framework and the app had to be rebuilt. None of this was a blocker, but it did slow iteration compared to a fully native setup.

What Worked Well

Despite the challenges, several decisions paid off consistently.

The monorepo dramatically improved collaboration. Engineers could follow data flow end to end in a single codebase, and code reviews naturally covered both shared and native changes. CI pipelines were easier to manage, and we saw fewer “it works in my module” discussions.

Keeping the UI native while sharing business logic and resources struck the right balance. We avoided duplicating rules, copy and configuration while keeping full control over how the app feels on iOS. When the Android app arrives, it will inherit a mature, tested shared layer instead of starting from zero.

The ViewModel-Store pattern helped keep SwiftUI predictable. Views were bound to simple observable properties, while more complex behavior lived in the shared ViewModels. Most of the heavy lifting could be tested in pure Kotlin, and the Swift side focused on composition and platform details.

Over time, working this way naturally spreads KMP knowledge across the team. iOS engineers became more comfortable reading and modifying Kotlin where it made sense. The shared layer stopped feeling like a black box owned by a separate group.

Key Advice for Teams Considering KMP

Based on this project, we would approach future KMP work with a few concrete principles.

  • Use KMP to centralize business and data logic: Treat it as a shared backbone for rules, data flow and state, not as a shortcut to avoid platform-specific UI. Shared UI is a separate decision to adopt Compose Multiplatform with its own trade-offs.
  • Start with a monorepo when layers evolve together: When shared logic and native apps move in parallel, a monorepo reduces coordination overhead, simplifies CI and makes cross-layer debugging far easier than juggling multiple repositories and binary releases.
  • Design shared APIs with Swift interop in mind: Keep models and contracts simple and explicit. Avoid features that translate poorly, such as sealed hierarchies in public interfaces.
  • Be intentional about memory and concurrency at the boundary: Don’t assume thread guarantees or ownership behave the same way across platforms. Make the rules explicit and document them.
  • Start small and expand intentionally: Share a well-defined slice of logic, validate the workflow and tooling, and only then expand the shared surface. Growing a working setup is far easier than untangling an overly ambitious one.

Final Thoughts

This project confirmed that KMP can power real production apps when used with clear goals and realistic expectations. A strong shared layer, a monorepo and a native UI on top gave us a practical balance between reuse and platform quality.

When you treat KMP as a way to consolidate the parts of your app that truly benefit from being shared and keep everything else appropriately native, it can become a reliable backbone for future cross-platform work rather than another layer of complexity.

Share this article


Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.