Skip to main content
Kotlin Multiplatform Projects

Kotlin Multiplatform: Sharing Code Between iOS and Android Without the Headaches

Cross-platform development has long been a trade-off between code reuse and native quality. Kotlin Multiplatform (KMP) offers a middle path: share business logic in Kotlin while keeping platform-specific UI and APIs. But teams often stumble on integration details, tooling quirks, and unexpected platform divergence. This guide cuts through the noise, showing you how to set up KMP, avoid common mistakes, and decide when shared code actually reduces headaches. Why Share Code? The Real Pain Points Maintaining separate iOS and Android codebases means duplicating business logic, data models, networking, and validation. Every feature requires two implementations, two test suites, and two debugging sessions. The cost multiplies with each platform-specific nuance. KMP addresses this by letting you write shared code once in Kotlin, which compiles to platform binaries for both targets. But the promise only holds if the shared layer is designed well.

Cross-platform development has long been a trade-off between code reuse and native quality. Kotlin Multiplatform (KMP) offers a middle path: share business logic in Kotlin while keeping platform-specific UI and APIs. But teams often stumble on integration details, tooling quirks, and unexpected platform divergence. This guide cuts through the noise, showing you how to set up KMP, avoid common mistakes, and decide when shared code actually reduces headaches.

Why Share Code? The Real Pain Points

Maintaining separate iOS and Android codebases means duplicating business logic, data models, networking, and validation. Every feature requires two implementations, two test suites, and two debugging sessions. The cost multiplies with each platform-specific nuance. KMP addresses this by letting you write shared code once in Kotlin, which compiles to platform binaries for both targets. But the promise only holds if the shared layer is designed well.

The Hidden Cost of Duplication

Consider a typical app with authentication, caching, and analytics. Without sharing, each platform team implements the same OAuth flow, same SQLite queries, and same event tracking. Bugs appear in one platform but not the other; feature parity drifts. KMP eliminates this duplication for pure logic, but it does not magically solve platform-specific UI or API differences. The key is to isolate shared logic from platform code using clear boundaries.

When Sharing Backfires

Sharing too aggressively—like forcing a shared network layer that abstracts away platform-specific certificate pinning—can introduce more complexity than it saves. Teams often overestimate how much code is truly identical. For example, date formatting, file I/O, and threading models differ between platforms. KMP's expect/actual mechanism handles these differences, but each actual declaration adds maintenance. The goal is to share only the core domain logic, not every utility function.

In practice, a well-structured KMP project shares 40–70% of code, depending on the app's reliance on platform APIs. The remaining 30–60% is UI and platform glue. This ratio is healthy; trying to push beyond it leads to convoluted abstractions.

How KMP Works: Core Concepts

KMP uses the same Kotlin language and standard library across platforms, but compiles to different targets: JVM bytecode for Android and native binaries (via LLVM) for iOS. The shared code resides in a common module, while platform-specific code lives in androidMain and iosMain source sets.

Expect/Actual Declarations

When you need platform-specific functionality—like getting the app version or accessing secure storage—you declare an expect function or class in common code and provide actual implementations per platform. For example:

// commonMain
expect fun getPlatformName(): String

// androidMain
actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"

// iosMain
actual fun getPlatformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"

This mechanism is simple for small APIs, but it scales poorly if you have many expect/actual pairs. A better approach is to define interfaces in common code and inject platform implementations via a dependency injection framework.

Dependency Injection and Architecture

Using a DI framework like Koin or kotlin-inject, you can define shared interfaces and provide platform-specific bindings. This keeps common code free of actual declarations and makes testing easier: you can mock the interface in common tests. For instance, define a PlatformContext interface with methods like getAppVersion() and provide Android and iOS implementations in their respective source sets.

Networking is another area where KMP shines. Libraries like Ktor Client work cross-platform, with engine implementations for OkHttp (Android) and Darwin (iOS). You write the API client once in common code, and the engine is selected at compile time. Similarly, kotlinx.serialization handles JSON parsing without platform-specific code.

Setting Up a KMP Project: Step-by-Step

This section walks through creating a new KMP project from scratch, targeting iOS and Android. We assume you have Android Studio and Xcode installed.

Step 1: Create the Project

Use the KMP wizard in Android Studio (File > New > New Project > Kotlin Multiplatform App). Choose a project name and enable iOS and Android targets. The wizard generates a shared module with commonMain, androidMain, and iosMain source sets, plus an Android app module and an iOS Xcode project.

Step 2: Configure Dependencies

In the shared module's build.gradle.kts, add dependencies for Ktor, kotlinx.serialization, and a DI framework. For example:

commonMain.dependencies {
    implementation("io.ktor:ktor-client-core:2.3.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
    implementation("io.insert-koin:koin-core:3.4.0")
}
androidMain.dependencies {
    implementation("io.ktor:ktor-client-okhttp:2.3.0")
}
iosMain.dependencies {
    implementation("io.ktor:ktor-client-darwin:2.3.0")
}

Ensure the Kotlin plugin version matches across modules. A common mistake is version mismatch between the shared and app modules, causing cryptic build errors.

Step 3: Write Shared Code

Create a Greeting class in commonMain that uses Ktor to fetch data from an API. Use expect/actual for platform-specific dependencies like PlatformContext. Keep the shared code pure: no Android or iOS imports.

Step 4: Integrate with Android

In the Android app module, add a dependency on the shared module. Call shared functions from ViewModels or Activities. The Android app runs on JVM, so the shared module compiles to a JAR.

Step 5: Integrate with iOS

The iOS integration is more involved. The shared module compiles to a framework (e.g., shared.framework). In Xcode, add this framework to the iOS target's build phases. Create a wrapper class in Swift that exposes Kotlin objects via a helper function, because Kotlin's generated Objective-C headers may not be Swift-friendly. For example, use a KoinHelper class that initializes the DI container and returns instances.

One common headache is the build cache: Xcode may not detect changes in the Kotlin framework. To mitigate, add a build phase script that runs the Gradle task embedAndSignAppleFrameworkForXcode before compiling Swift code.

Tools, Stack, and Maintenance Realities

KMP's ecosystem is maturing but still has rough edges. Below is a comparison of key libraries and their trade-offs.

CategoryOptionsProsCons
NetworkingKtor, Retrofit (Android only)Ktor is cross-platform; Retrofit is mature on AndroidKtor has a steeper learning curve; Retrofit requires platform-specific code
Serializationkotlinx.serialization, Moshikotlinx.serialization works in common code; Moshi is Android-firstkotlinx.serialization has limited custom serializer support
DIKoin, kotlin-inject, Dagger/HiltKoin is simple and cross-platform; Hilt is Android-onlyKoin may have runtime overhead; kotlin-inject requires KSP
Testingkotlin.test, MockKCommon tests run on JVM; MockK works cross-platformiOS-specific tests require running on device/simulator

CI/CD Considerations

Building for iOS requires a macOS environment. If you use GitHub Actions, you need a macOS runner. The build pipeline must run Gradle tasks for the shared module, then build the iOS app with xcodebuild. Caching the Gradle build artifacts can significantly speed up CI. Also, ensure the Kotlin framework is embedded correctly; otherwise, the iOS app will crash at runtime with unresolved symbols.

Version Compatibility

KMP versions evolve rapidly. A library that works with Kotlin 1.9 may break with Kotlin 2.0. Pin your Kotlin version and test library compatibility before upgrading. The community maintains a compatibility table, but it's not always up-to-date.

Growth Mechanics: Scaling Shared Code

As your project grows, the shared module can become a monolith. Split it into feature-specific modules (e.g., :shared:auth, :shared:network). Each module has its own commonMain, androidMain, and iosMain source sets. This improves build times and allows teams to work independently.

Handling Platform-Specific UI

KMP does not replace UI frameworks. For iOS, you still use SwiftUI or UIKit; for Android, Jetpack Compose or XML. However, you can share ViewModel-like classes that hold state and business logic. On Android, use a ViewModel from shared code; on iOS, wrap it in an ObservableObject. This pattern (often called MVI or MVVM) reduces UI code duplication.

For example, a shared LoginViewModel exposes a StateFlow of UI state. Android's Compose collects it via collectAsState(); iOS uses a Combine publisher or a callback. The logic for validation, API calls, and error handling lives once in shared code.

Third-Party Library Availability

Not all Android libraries have KMP equivalents. If you rely heavily on Android-specific APIs (e.g., CameraX, Room with KSP), you may need to wrap them in expect/actual. Consider using KMP-friendly alternatives: SQLDelight for databases, Ktor for networking, and kotlinx-datetime for date/time.

For analytics, you can define a shared interface and implement it per platform using native SDKs (Firebase on Android, Firebase on iOS or a custom wrapper). This keeps the integration point small.

Risks, Pitfalls, and Mitigations

Build Configuration Drift

One of the most common headaches is the Gradle and Xcode build configuration getting out of sync. For instance, if you add a new dependency in the shared module but forget to update the iOS framework embedding, the app crashes. Mitigation: use a Gradle task that automatically embeds the framework and add it as a build phase in Xcode. Also, run the iOS app locally after every shared module change.

Serialization and Enum Differences

Kotlin enums serialize differently on Android (as ordinal or name) and iOS (as name by default). Use @SerialName annotations to ensure consistent serialization. Also, avoid using sealed class with complex hierarchies in shared code, as they may not map cleanly to Objective-C.

Performance Overhead

KMP adds a thin layer of abstraction, but for most apps the performance impact is negligible. However, if you use reflection-heavy libraries or create many short-lived objects, you may see overhead on iOS. Profile with Instruments and optimize hot paths by moving them to actual implementations.

Testing on iOS

Common tests run on JVM, but iOS-specific tests require a simulator or device. You can write unit tests for iOS in Swift that call into the shared framework, but mocking is limited. Consider using a test-only actual implementation that returns stubs. Alternatively, keep most logic in common code and test it on JVM.

Mini-FAQ and Decision Checklist

Frequently Asked Questions

Q: Can I use KMP with an existing app? Yes. You can add a shared module incrementally. Start by extracting a small piece of logic (e.g., data models, validation) and integrate it on both platforms. Gradually move more code as you gain confidence.

Q: How do I handle push notifications? Push notification registration is platform-specific. Keep the registration logic in platform code, but share the payload parsing and handling logic in common code.

Q: What about SwiftUI interoperability? You can call Kotlin functions from SwiftUI by wrapping them in an ObservableObject. Use a helper class that publishes state changes. This works well for simple cases but may require bridging for complex types.

Decision Checklist

Before adopting KMP, consider these criteria:

  • Your app has significant business logic beyond CRUD.
  • You have a team comfortable with Kotlin (or willing to learn).
  • You can tolerate occasional tooling instability.
  • You need to share networking, caching, or data layers.
  • You are not heavily dependent on platform-specific UI frameworks (e.g., ARKit, Android Widgets).

If most items apply, KMP is a strong fit. If your app is mostly UI with thin logic, Flutter or React Native may be simpler.

Synthesis and Next Steps

Kotlin Multiplatform is a pragmatic choice for teams that want to share code without sacrificing native experience. The key to success is disciplined architecture: isolate shared logic, use expect/actual sparingly, and invest in a solid CI pipeline. Start small, iterate, and resist the temptation to share everything.

Your next action: create a proof-of-concept shared module that handles one feature—like user authentication. Integrate it on both platforms and measure the time saved versus dual implementation. That real-world feedback will guide your adoption roadmap.

Remember, KMP is not a silver bullet. It requires ongoing maintenance and careful design. But when applied correctly, it reduces duplication, improves consistency, and lets your team focus on what makes your app unique.

About the Author

Prepared by the editorial contributors at languor.xyz. This guide is written for mobile developers and technical leads evaluating Kotlin Multiplatform for production use. We reviewed official documentation, community discussions, and real project experiences to ensure practical accuracy. As the KMP ecosystem evolves, verify library versions and tooling compatibility against current releases.

Last reviewed: June 2026

Share this article:

Comments (0)

No comments yet. Be the first to comment!