Kotlin Multiplatform from an iOS engineer's perspective
I've been integrating KMP into a production iOS app at my current job to share business logic with Android. Here's what it actually feels like to work with — the good, the surprising, and the places where Swift is still irreplaceable.
I’ve spent most of my career writing Swift. I think in terms of protocols, value types, and actors. Kotlin has always been something I understood conceptually but hadn’t seriously engaged with since I focus on iOS.
That changed at my current job when we needed to integrate Kotlin Multiplatform into our existing iOS app. The Android team had built a shared business logic layer in KMP and my goal was to consume it on iOS instead of duplicating the logic in Swift. After working with this approach, I have some thoughts.
What KMP Actually Is (and Isn’t)
Kotlin Multiplatform is primarily a technology for sharing code across platforms, not a UI framework in itself. In practice, most teams use it to share the logic layer: networking, data parsing, business rules, caching strategies, local database schemas. In our case, the UI stayed fully native on each platform.
This distinction matters. Shared UI is possible in the Kotlin ecosystem with tools like Compose Multiplatform, but that wasn’t what we were adopting. We were sharing business logic, which is usually the cleaner place to standardise anyway — a CartTotalCalculator that applies regional tax rules and promotional discounts doesn’t care whether it runs on iOS or Android.
In our setup, the iOS integration point was a compiled .xcframework. The Android team wrote Kotlin, ran a build task, and produced a binary that Xcode could link against. From the iOS perspective, it arrived as a framework that you import and call.
The iOS Developer Experience
Adding the framework to your Xcode project is standard: drag the .xcframework into your project, link it to your target, and import the module. So far, so familiar.
The experience starts to diverge when you look at what gets generated. Kotlin types are mapped to Objective-C headers (yes, Objective-C — not Swift), and then Swift’s bridging layer makes them available to your code. This has a few practical consequences.
Generics are limited. Kotlin generics still lose some fidelity when they cross the Apple interop boundary. Collections like List<Article> can bridge more naturally to Swift than raw Kotlin arrays do, but generic type information is still often less ergonomic than a native Swift API, especially once Objective-C interop is involved. This usually means wrapper layers. (Note: Kotlin 2.0 introduced experimental Swift export that bypasses the Obj-C bridge entirely. It improves several rough edges, but it wasn’t production-ready when we did our integration.)
Coroutines require some care. Kotlin’s coroutines — the primary concurrency primitive — can be exposed to Swift as completion handlers and, in newer toolchains, also as async functions. In practice, though, the default interop still has enough caveats that many teams use SKIE (from TouchLab) or write adapters to get a smoother iOS experience. SKIE is impressively good, but it adds a dependency and a build step.
Here’s what consuming a Kotlin suspend function looks like with SKIE:
// Kotlin (shared module)
suspend fun fetchUser(id: String): User
// Swift, with SKIE — the suspend function becomes async
let user = try await sharedModule.fetchUser(id: userId)
Without SKIE, you’re more likely to run into callback-heavy APIs and rougher coroutine interop. It’s possible to integrate without it, but the ergonomics are noticeably worse.
Exception bridging is not very Swifty. On Apple platforms, Kotlin exception interop is shaped by @Throws annotations and suspend-function bridging rules rather than mapping cleanly to Swift’s native error design. In practice, iOS usually sees generic Error/NSError-style failures rather than rich domain-specific error types, so if the Android team wants precise error semantics to survive the bridge, it’s better to expose explicit result types.
Where KMP Genuinely Wins
With those caveats stated, here’s where the shared layer pulled real weight.
The networking and parsing code is genuinely shared. The API models, JSON parsing logic, request construction, and error handling were written once and tested once. When the backend changed a response schema, the Android team updated the Kotlin model and we got the update for free. No iOS-side change required beyond pulling the new framework version.
Business rule consistency. In the past, I’ve seen iOS and Android developers implement the same validation rules independently, which inevitably leads to drift. KMP eliminates that category of bug entirely for the rules that live in the shared module.
The test coverage is cumulative. The Kotlin code has its own test suite running on the JVM. When we pull the compiled framework, those tests have already run. We don’t need to duplicate them on the iOS side for the shared logic. We test our Swift wrappers and our integration — not the business logic the Android team already validated.
Where It Breaks Down
The most frustrating part: the feedback loop. If the Android team makes a change to the shared module, we don’t get it until they publish a new version of the framework. In a monorepo with proper tooling, this can be fast. In a multi-repo setup where the .xcframework is published to a GitHub release and consumed via SPM, it adds coordination overhead.
I’ve experienced multi-day windows where my work on the iOS app was blocked on a shared module change that an Android developer hadn’t gotten to yet because they were focused on their own sprint. That kind of blocking dependency is new for an iOS engineer used to owning their entire stack.
Swift-idiomatic API design is an afterthought. The Android team naturally writes Kotlin idiomatically. data class hierarchies, Kotlin-specific null handling, sealed classes. Mapping these to Swift APIs that feel native — that use Result<T, E>, enums with associated values, Sendable conformances — requires explicit effort, usually in the form of a Swift wrapper layer that your iOS team writes and owns.
That wrapper layer is real work. Budget for it.
My Architecture Recommendation
If you’re setting up a KMP integration from scratch, I’d structure it like this:
Kotlin side: Expose clean result types, not throwing functions. Something like:
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
}
iOS side: Write a thin Swift adapter layer that translates NetworkResult to a Swift Result<T, AppError> and wraps all Kotlin calls behind protocols:
protocol UserRepositoryProtocol {
func fetchUser(id: String) async -> Result<User, AppError>
}
class KMPUserRepository: UserRepositoryProtocol {
private let sharedModule: SharedUserModule
func fetchUser(id: String) async -> Result<User, AppError> {
switch await sharedModule.fetchUser(id: id) {
case let success as NetworkResultSuccess<SharedUser>:
return .success(User(from: success.data))
case let error as NetworkResultError:
return .failure(.network(code: Int(error.code), message: error.message))
default:
return .failure(.unknown)
}
}
}
Your iOS business logic talks to UserRepositoryProtocol. It doesn’t know about Kotlin. The Kotlin-specific ugliness is contained in KMPUserRepository. When you want to test your iOS code, you inject a mock UserRepositoryProtocol. The KMP implementation becomes a deployment detail, not an architectural constraint.
The Honest Verdict
KMP is a genuinely viable approach for sharing business logic between iOS and Android. It’s not as seamless as the marketing material suggests, and the iOS developer experience is unmistakably second-class relative to the Kotlin-native experience. But “second-class” doesn’t mean “bad” — it means you need to put thought into the Swift wrapper layer and keep the Kotlin-specific surface area as small as possible.
For teams where sharing business logic eliminates a real, demonstrated source of drift or duplication, the tradeoff is worth it. For teams considering it to avoid writing Swift at all, or as a path toward shared UI in the future: that’s not what KMP is, and you’ll be disappointed.
Share the logic. Keep the UI native. That’s the value proposition, and it delivers.