Six Months with Kotlin Multiplatform: A Production Retrospective
Six months ago, our team hit a maintenance wall. We were managing three independent codebases for a fintech app: Android (Kotlin), iOS (Swift), and a Web dashboard (TypeScript). Whenever interest rate calculation logic changed, we had to update three separate repositories.
This inevitably led to three different sets of bugs and tripled our QA workload. To solve this, we migrated our core business logic to Kotlin Multiplatform (KMP). After half a year in production, our development velocity has increased by roughly 40%.
Unlike Flutter or React Native, which attempt to control the entire UI stack, KMP focuses on sharing the parts that actually matter: the logic. You keep your native UI—SwiftUI for iOS and Jetpack Compose for Android—but you write your data models, networking, and validation logic only once. If you want to scale a mobile product without doubling your engineering headcount every year, mastering KMP is no longer optional.
Quick Start: Launching Your First Project in 5 Minutes
Setting up KMP used to be a tedious manual process, but the ecosystem has matured. The JetBrains web wizard now handles the heavy lifting of configuration.
1. Environment Check
You will need Android Studio and Xcode. Before writing any code, install kdoctor. This tool identifies missing dependencies like CocoaPods or specific Java versions that often break builds:
brew install kdoctor
kdoctor
If the output shows all green checkmarks, your machine is ready for multiplatform development.
2. Generate the Project
Head to kmp.jetbrains.com. Select Android, iOS, and Web (Wasm). Once you download and open the project in Android Studio, you will see three main directories:
composeApp/: Shared UI code (if you choose to use Compose Multiplatform).iosApp/: A standard Xcode project that consumes your shared logic.shared/: The engine room where your cross-platform business logic lives.
3. Run the App
Select the “composeApp” configuration to run on an Android emulator. For iOS, you can launch the app directly from Android Studio using the KMP plugin. Alternatively, open iosApp/iosApp.xcworkspace in Xcode to debug on a physical iPhone.
The “Expect/Actual” Mechanism: Bridging Platform Gaps
KMP does not force you into a “lowest common denominator” sandbox. If you need to access platform-specific APIs, such as the file system or a device UUID, you use the expect/actual pattern. This is the secret sauce of KMP’s flexibility.
Defining the Expectation
In your commonMain folder, you define the interface you need:
// shared/src/commonMain/kotlin
expect fun getPlatformName(): String
Implementing the Reality
The compiler then requires you to provide implementations in the platform-specific folders. It effectively injects the correct code at compile time.
// androidMain
actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
// iosMain
import platform.UIKit.UIDevice
actual fun getPlatformName(): String = UIDevice.currentDevice.systemName()
Networking and Serialization
For production-grade apps, I recommend Ktor for HTTP requests and kotlinx.serialization for JSON. This combination allows you to write a single API client that works everywhere. By defining your repository in commonMain, your Android, iOS, and Web apps share identical network logic and data models. This alone reduced our logic-related bugs by 30% in the first quarter.
Advanced Usage: State Management and Web (Wasm)
Sharing networking is great, but sharing the “ViewModel” logic is where you see the real gains. By using Kotlin Coroutines and Flow, you can create a centralized StateHolder.
Shared State with Flow
We structure our shared logic using a reactive UI State pattern. This ensures that the UI remains a “dumb” reflection of the shared state.
class SharedViewModel {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun loadData() {
// Coroutine logic goes here
}
}
Android consumes this via Jetpack Compose effortlessly. On iOS, you can collect these Flows within a SwiftUI ObservableObject. For the Web, Kotlin/Wasm allows you to compile this same code into WebAssembly. This results in near-native performance in the browser, bypassing the overhead of traditional JavaScript bridges.
Practical Tips from the Trenches
Migrating a large-scale app taught us that KMP requires a strategic approach. It is not a silver bullet, and you must avoid over-engineering your shared modules.
1. Don’t Over-Abstract the UI
It is tempting to share every single button and animation. However, we found that complex UI animations are often easier to write natively. Share your Logic, Data, and State, but let the platform-specific UI frameworks handle the pixels where it makes sense.
2. Fix the Swift Interop with SKIE
Debugging Kotlin code on an iPhone simulator can be frustrating because Swift doesn’t natively understand Kotlin’s Sealed Classes or Flows. Use the Touchlab SKIE plugin. It generates Swift-friendly code, making your shared Kotlin modules feel like native Swift libraries. Without it, your iOS developers will likely struggle with clunky generic types.
3. CI/CD and Build Costs
Your build pipeline will now require a macOS runner to compile the iOS targets. Be aware that GitHub Actions macOS runners are significantly more expensive than Linux runners. We optimized this by only running iOS build checks on pull requests that touch the shared or iosApp directories.
4. Avoid the Expect/Actual Trap
Write your own expect/actual code only as a last resort. The KMP ecosystem is vast. If a library like SQLDelight exists for local databases, use it. The less platform-specific code you maintain, the easier your project will be to upgrade in the future.
The initial learning curve for KMP takes about a week. However, the long-term benefits are massive. Watching a single bug fix propagate to Android, iOS, and Web simultaneously is a game-changer for any engineering team.

