Email copiado — support@tuurt.com
Cargando experiencia
kotlin · October 12, 2026 · 5 min

Kotlin Multiplatform for Android and iOS: what we actually share, and where the platform still wins

We took Kotlin Multiplatform into real apps and learned where sharing code actually saves work, and where it ends up costing more than two native implementations.

By Tuurt Team

Kotlin Multiplatform for Android and iOS: what we actually share, and where the platform still wins

Kotlin Multiplatform comes with a simple pitch: write business logic once, compile it for both Android and iOS, and let each platform keep its own native UI on top. That pitch holds up, but the line between "this shares cleanly" and "this costs more shared than duplicated" isn't where the docs draw it. After taking KMP through a couple of apps with non-trivial business logic, the boundary that actually works is narrower than the one we promised ourselves at kickoff.

What shares cleanly: data models and pure domain logic

The obvious candidates for commonMain are also the ones with the fewest surprises: data classes, validation, calculations, and any transformation that only depends on standard Kotlin types. A domain model like this has no reason to exist twice:

// commonMain
data class Order(
    val id: String,
    val items: List<OrderItem>,
    val status: OrderStatus
) {
    fun total(): BigDecimal =
        items.fold(BigDecimal.ZERO) { acc, item -> acc + item.subtotal() }
}

This compiles identically on Android and iOS because it touches nothing platform-specific: no IO, no UI, no system API. The same goes for form validation, the business rules that decide whether an order can be cancelled, or any parsing and formatting that doesn't depend on OS-level locale behavior. In the projects where we migrated this layer, it required the least rework and introduced the fewest platform-specific bugs, because the behavior is literally the same bytecode compiled twice, not a reimplementation we're hoping behaves the same way on both sides.

Repositories share well too, with one condition: the interface lives in commonMain, and whatever implementation touches a platform-specific API lives in the matching source sets.

// commonMain
interface OrderRepository {
    suspend fun getOrder(id: String): Order
    suspend fun saveOrder(order: Order)
}

Caching logic, retry policy, mapping network errors to domain errors: all of that lives in common code because those are business decisions, not platform decisions. What changes between Android and iOS is the underlying HTTP client and the persistence engine, and that's where expect/actual comes in.

Where expect/actual starts costing more than it saves

expect/actual is the mechanism that lets you declare an API in common code and give it a different implementation per platform. It works well when the API surface is small and stable: reading the system clock, generating UUIDs, reading a simple preference. The trouble starts when the underlying platform APIs aren't symmetric, and we end up writing a shared abstraction that doesn't represent either platform particularly well.

The case where this got expensive for us was local persistence. We wanted to abstract SQLDelight (which is multiplatform out of the box) behind our own interface so that, in theory, we could swap persistence engines without touching common code. In practice, the differences between how Android handles background transactions through WorkManager and how iOS manages a background task's lifecycle through BGTaskScheduler meant our common interface ended up with optional parameters that only made sense on one platform, or callbacks that simply never fired on the other. We kept that abstraction for several sprints before admitting we were paying the cost of designing and maintaining a generic interface without getting anything back for it, since we never had a real intention of swapping persistence engines. The fix was to keep the logic of what to save and when in common code, and leave the how — the transaction details, the background lifecycle handling — as platform-specific code, without forcing it into a shared interface.

Something similar happens with anything that depends on system permissions, push notifications, or sensor access. expect/actual for these APIs tends to degenerate into an interface that exposes the lowest common denominator between Android and iOS, which in practice means neither platform can use its full capabilities through that layer, and sooner or later someone bolts on a platform-specific method that breaks the abstraction it was supposed to provide.

What we still don't share: UI, navigation, and presentation state

Compose Multiplatform and SwiftUI remain separate worlds in our projects, and not for lack of tooling — Compose Multiplatform already supports iOS — but because the cost of forcing a single UI tree to look "native" on both platforms still doesn't pay off compared to maintaining two thin presentation layers that consume the same shared ViewModel.

// commonMain
class OrderViewModel(
    private val repository: OrderRepository
) {
    private val _state = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
    val state: StateFlow<OrderUiState> = _state

    suspend fun loadOrder(id: String) {
        _state.value = OrderUiState.Loading
        _state.value = try {
            OrderUiState.Ready(repository.getOrder(id))
        } catch (e: Exception) {
            OrderUiState.Error(e.message ?: "Unknown error")
        }
    }
}

This ViewModel in commonMain exposes a StateFlow that both an Android Activity and an iOS ViewController can observe, each through its own native binding mechanism. We share the logic of what state to show and when; we don't share how that state gets drawn. This boundary — share presentation logic, not rendering — has given us the best ratio of shared code to maintenance friction, well ahead of any attempt to also share the view tree itself.

We don't share navigation either. Each platform has its own paradigm — NavController on Android, NavigationStack on iOS — and the few times we tried abstracting it behind a common layer, we ended up with an API that respected neither Android's deep-linking patterns nor iOS's native swipe-back behavior particularly well.

What we check before moving something into common code

Before moving a layer into commonMain, we confirm its behavior doesn't depend on any platform API even indirectly, because a dependency hiding in there eventually surfaces as a poorly disguised expect/actual; we'd rather keep two native implementations than force a shared interface when the underlying platform APIs aren't symmetric between Android and iOS; we share the what and when (domain logic, presentation state, business rules) and leave the how (low-level persistence, background lifecycle, rendering) as native code; and we re-review every existing expect/actual abstraction whenever we add a platform or a new feature, because the one that made sense with two simple use cases can become the most expensive thing to maintain in the project the moment the first real-world use case doesn't fit the mold we designed for it.

kotlin-multiplatform android ios mobile-architecture
← Back to blog