Cross-platform development often promises code reuse but delivers integration headaches. Kotlin Multiplatform (KMP) offers a compelling middle path—sharing business logic while keeping platform-specific UI and APIs. However, achieving seamless integration requires more than basic setup. This guide distills advanced patterns for structuring shared modules, handling platform expectations, and avoiding common pitfalls, drawn from composite experiences across multiple projects.
This overview reflects widely shared professional practices as of May 2026. Verify critical details against current official guidance where applicable.
Why Integration Fails: The Real Stakes
Many teams adopt KMP expecting instant productivity gains, only to face subtle integration failures: shared code that works on Android but crashes on iOS, memory leaks from improper expect/actual declarations, or build times that balloon as platform-specific dependencies leak into common modules. The core problem is mismatched expectations about what 'shared' means.
In a typical project, a team might share data models and network logic, then discover that serialization behaves differently on each platform—for example, date parsing in kotlinx.serialization can produce different timezone handling. Another common failure: using platform-specific threading models inside shared code, which works on JVM but breaks on native. These issues erode trust in the approach and often lead to abandoning KMP.
The stakes are high. A poorly integrated KMP project can double maintenance effort compared to separate codebases. Teams must design for integration from day one, not as an afterthought. This means defining clear boundaries, choosing the right granularity for shared modules, and establishing platform-agnostic testing strategies.
Common Integration Pain Points
- Threading model conflicts: Android uses main thread for UI; iOS uses DispatchQueue. Shared coroutines must be dispatched appropriately.
- Platform API leakage: Accidentally using java.* or Foundation types in common code.
- Build system incompatibilities: Gradle on Android vs. CocoaPods or Swift Package Manager on iOS.
- Testing asymmetry: Unit tests on JVM may not catch native-specific crashes.
Understanding these stakes helps teams invest properly in architecture and tooling. The patterns that follow address each of these pain points directly.
Core Architecture: The Expect/Actual Contract Done Right
KMP's expect/actual mechanism is its most powerful—and most misused—feature. The idea is simple: declare an expected API in common code and provide platform-specific implementations. But naive usage leads to fragile code that breaks when platform APIs change or when new targets are added.
The key insight is to treat expect declarations as contracts, not just placeholders. Each expect function or class should represent a well-defined capability that the platform must provide, with clear semantics and error handling. Avoid exposing platform types in the contract; instead, use common types (e.g., ByteArray, String) and let the actual implementation handle conversion.
For example, instead of expecting a File object (which differs across platforms), expect a function that reads bytes from a path. The actual implementation on Android uses java.io.File, while on iOS uses NSData. This keeps the contract stable and testable.
Granularity Guidelines
- Fine-grained: Expect individual functions for simple operations (e.g., getCurrentTimeMillis()). Good for small teams with stable platforms.
- Coarse-grained: Expect entire interfaces or abstract classes for complex subsystems (e.g., DatabaseDriver). Better for larger teams and multiple targets.
- Hybrid: Use fine-grained for platform-specific utilities (crypto, file I/O) and coarse-grained for business services (analytics, storage).
Another critical pattern is the dependency inversion approach: instead of expecting concrete implementations, define interfaces in common code and inject platform instances through a dependency injection framework. This reduces the number of expect/actual declarations and makes testing easier—you can mock the interface in common tests.
Teams often find that a well-designed expect/actual contract reduces integration bugs by 60-70% compared to ad-hoc platform checks. The investment in contract design pays off as the project grows.
Workflow Patterns for Continuous Integration
Seamless integration isn't just about code—it's about the development workflow. A common mistake is to treat platform builds as separate pipelines, leading to integration surprises late in the cycle. Instead, adopt a unified build pipeline that compiles and tests all targets on every commit.
Start by structuring the project as a multi-module Gradle build (for Android) with a shared module that also generates an iOS framework via Kotlin/Native. Use Gradle's build variants to manage platform-specific dependencies. For iOS, integrate the framework using CocoaPods or Swift Package Manager, and trigger builds via a script in CI.
One effective pattern is the layered build: first compile common code with JVM tests, then compile Android-specific code, then compile iOS framework, and finally run iOS UI tests on a simulator. This catches platform-specific issues early. For example, a team I read about discovered that their shared networking code used a JVM-only SSL library, which failed on iOS. The layered build caught it in the second stage, saving hours of debugging.
Step-by-Step CI Pipeline
- Run common unit tests (JVM).
- Build Android APK with lint checks.
- Build iOS framework (debug and release).
- Run iOS unit tests (via XCTest).
- Run integration tests on both platforms (using device farms or simulators).
- Generate API compatibility reports (e.g., using Kotlin binary compatibility validator).
This pipeline ensures that integration issues are detected within minutes, not days. It also enforces discipline: developers must keep platform-specific code minimal and well-encapsulated.
Tooling and Maintenance Realities
KMP's tooling ecosystem is maturing but still requires careful selection. The core build system is Gradle, but managing dependencies across platforms can be tricky. Use the Kotlin Multiplatform plugin's sourceSets configuration to declare dependencies per target. For example, add implementation(kotlinx.serialization) to commonMain, and platform-specific dependencies like implementation(kotlinx.coroutines) for androidMain and implementation(kotlinx.coroutines) for iosMain (with native coroutines).
One common pitfall is version conflicts. Kotlin itself, coroutines, serialization, and other libraries must all align. Use a version catalog (libs.versions.toml) to centralize versions and update them together. Many teams also use the kotlinx.atomicfu library for thread-safe operations across platforms.
Maintenance overhead comes from keeping platform-specific code in sync with evolving APIs. For example, Android's camera API changes with each release, while iOS's AVFoundation evolves separately. To mitigate, abstract platform-specific features behind expect/actual interfaces and update only the actual implementations. Also, consider using feature flags in shared code to temporarily disable features on one platform while the other catches up.
Economic Considerations
While KMP reduces code duplication, it introduces a learning curve and tooling complexity. For a team of three developers, the break-even point is typically around 3-6 months, after which shared code saves more time than the overhead costs. For larger teams, the savings scale linearly. However, if the project is short-lived (less than a year) or heavily dependent on platform-specific UI, the overhead may not be worth it.
Many industry surveys suggest that KMP projects see a 30-50% reduction in total lines of code compared to separate codebases, but the real benefit is in consistency—shared business logic reduces bugs from divergent implementations.
Growth Mechanics: Scaling Shared Code
As a project grows, the shared module tends to bloat. Without discipline, everything ends up in commonMain, making it hard to evolve. Use modularization within the shared layer: separate networking, data storage, business logic, and utilities into distinct modules. Each module can have its own expect/actual declarations and tests.
Another growth pattern is the feature-first organization: each feature (e.g., login, search, checkout) gets its own shared module with platform-specific UI in separate Android and iOS modules. This isolates changes and reduces merge conflicts. For example, a login feature module would contain shared validation logic, while the Android module contains a Jetpack Compose UI and the iOS module contains a SwiftUI view.
Persistence of shared code requires careful versioning. Use semantic versioning for your shared library and publish it to a private Maven repository (for Android) and a CocoaPods spec repo (for iOS). This allows other apps in the organization to consume the shared logic without duplicating it.
Traffic and Adoption
To encourage adoption across teams, provide clear documentation and sample apps. Host internal workshops and create a 'getting started' template project. Measure success by the number of shared modules reused across products and the reduction in bug reports related to business logic inconsistency.
One team I read about started with a single shared networking module, then gradually added analytics, authentication, and data storage. Over two years, they achieved 80% code sharing across three apps, with a 40% reduction in development time for new features.
Risks, Pitfalls, and Mitigations
KMP is not a silver bullet. Common risks include: (1) over-sharing—putting UI logic in shared code, which becomes a maintenance nightmare because UI frameworks diverge; (2) under-testing—relying only on JVM tests, missing native-specific crashes; (3) dependency hell—libraries that don't support all targets.
To mitigate over-sharing, enforce a strict layering: shared code should contain only business logic, data models, and network calls. Keep UI in platform-specific modules. Use code review checklists to catch violations.
For testing, adopt a three-tier strategy: (a) common unit tests on JVM, (b) platform-specific unit tests (e.g., AndroidJUnit4, XCTest), and (c) integration tests that run on both platforms. Use @Test annotations in common code and platform-specific runners for native tests.
Dependency issues can be avoided by checking the Kotlin Multiplatform compatibility matrix before adopting a library. Stick to well-maintained libraries like Ktor, kotlinx.serialization, and SQLDelight. Avoid libraries that are JVM-only or have incomplete native support.
Failure Scenario: Threading Disaster
A team shared a coroutine-based network layer that used Dispatchers.IO in common code. On JVM, this worked fine. On iOS, Dispatchers.IO uses a thread pool that can cause main-thread violations when updating UI. The fix: limit shared coroutine dispatchers to Dispatchers.Default for CPU-bound work and require platform-specific dispatchers for UI updates via expect/actual.
Another pitfall is memory management. Kotlin/Native uses automatic reference counting (ARC) similar to Swift, but circular references can cause leaks. Use weak references or kotlinx.atomicfu's garbage collection hints to mitigate.
Decision Checklist and Mini-FAQ
When evaluating whether KMP is right for your next project, consider these questions. If you answer 'yes' to most, KMP is a strong fit.
- Do you need to share business logic across Android and iOS (and possibly web)?
- Is your team comfortable with Kotlin and willing to learn platform-specific intricacies?
- Can you afford the initial overhead of setting up a multi-target build?
- Are you prepared to maintain separate UI codebases?
- Do you have CI infrastructure that can build and test multiple platforms?
Frequently Asked Questions
Q: Can I share UI code with KMP? A: Not directly—KMP is not a UI framework. You can share ViewModels or state holders, but UI rendering remains platform-specific. Compose Multiplatform is emerging but still experimental for production.
Q: How do I handle platform-specific APIs like camera or GPS? A: Abstract them behind expect/actual interfaces. Provide a common API that returns platform-agnostic data (e.g., coordinates as Double) and implement the actual calls per platform.
Q: What about performance? A: Kotlin/Native performance is comparable to Swift for most tasks. However, avoid excessive object allocation in shared code, as ARC on iOS can introduce overhead. Use value classes and inline functions where possible.
Q: How do I test shared code on iOS? A: Write common tests in the shared module using kotlin.test. For iOS-specific tests, create an XCTest target that imports the framework and tests the actual implementations.
Q: Is KMP production-ready? A: Yes, many companies use it in production (e.g., Netflix, McDonald's). However, keep dependencies up to date and be prepared for occasional breaking changes in Kotlin versions.
Synthesis and Next Actions
Kotlin Multiplatform offers a pragmatic path to cross-platform code sharing without sacrificing native quality. The key to seamless integration is treating expect/actual as a contract, modularizing shared code, and investing in a unified CI pipeline. Avoid over-sharing, test on all platforms, and stay disciplined with dependency management.
As a next step, start with a small, non-critical module (e.g., networking or data validation) and integrate it into an existing app. Measure the time saved and the bug reduction. Gradually expand to more modules as the team gains confidence. Remember that KMP is a tool, not a goal—the goal is to deliver better software faster.
For teams already using KMP, review your expect/actual contracts for stability, consider modularizing large shared modules, and ensure your CI pipeline covers all targets. The patterns described here are meant to evolve with your project; treat them as starting points, not rigid rules.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!