Building a Kotlin Multiplatform (KMP) project that genuinely shares business logic across iOS and Android without constant platform workarounds is harder than it looks. Many teams start with a simple shared module, only to find themselves tangled in expect/actual declarations, platform threading mismatches, or bloated dependency graphs. This guide collects patterns we have used and seen succeed in production-grade KMP apps—focusing on integration seams that often break first. We assume you already know the basics of KMP setup; here we go deeper into architecture, state management, and testing strategies that keep cross-platform code both flexible and fast.
Why Integration Patterns Matter More Than Shared Code
The promise of KMP is write-once business logic, but the real friction is not in writing shared code—it is in connecting that code to platform UIs, sensors, databases, and third-party SDKs. A naive approach puts all shared code in one module with expect/actual declarations for every platform call. That works for a demo, but as the app grows, you end up with a tangled mess: platform code leaks into shared modules, tests become impossible to run without a device, and changing a dependency in one target breaks the other.
What we need instead is a clear boundary between shared logic and platform-specific infrastructure. This is where patterns like clean architecture, dependency inversion, and repository abstractions become essential. They are not just academic—they directly affect how many hours you spend debugging a crash that only happens on iOS or a memory leak that only shows up on Android. The patterns we describe here are the result of iterating on real projects where the shared module started as a thin layer and grew into the core of the application.
The Cost of Ignoring Integration Early
Teams that skip upfront design often face a predictable set of problems. First, the shared module becomes tightly coupled to a specific networking library (Ktor, for example) and a specific serialization format. When a platform team wants to use a different HTTP client for legacy reasons, the shared code resists the change. Second, state management becomes inconsistent: one platform uses LiveData, the other uses Combine, and the shared module tries to bridge both with awkward reactive wrappers. Third, testing becomes a chore because every test requires mocking platform dependencies that are declared with expect/actual, and running those tests on a CI server without a device is slow or impossible.
These problems are not theoretical. In a composite scenario we observed, a team spent three months rewriting their shared networking layer because they had hardcoded Ktor client creation inside a shared use case. The fix was to inject the client as a dependency—a pattern we cover in detail below. The lesson is simple: integration patterns are not optional extras; they are the difference between a maintainable KMP project and a rewrite waiting to happen.
Prerequisites: What You Need Before Diving Into Advanced Patterns
Before we discuss specific patterns, it helps to have a few things in place. First, your project should already compile and run on both targets with a minimal shared module. If you are still wrestling with Gradle configuration or CocoaPods integration, resolve those basics first—the patterns here assume a stable build environment. Second, your team should agree on a common vocabulary for architecture. We use a simplified clean architecture with three layers: data, domain, and presentation. The domain layer is pure Kotlin with no platform dependencies; the data layer contains repositories and data sources that may have expect/actual declarations; the presentation layer is platform-specific (SwiftUI, Jetpack Compose, etc.) and consumes domain models through view models or state holders.
Tooling and Dependency Management
We recommend using version catalogs in Gradle for dependency management—this keeps versions consistent across shared and platform modules. For dependency injection, we have had good results with Koin (multiplatform) because it is lightweight and does not require code generation. However, the patterns we describe work with any DI framework that supports constructor injection. For reactive streams, we prefer Kotlin Flows over RxJava or Combine because Flow is multiplatform-native and integrates well with both Android (via StateFlow) and iOS (via SKIE or custom adapters).
Another prerequisite is a testing strategy. We use a combination of pure unit tests (JVM), instrumented tests on Android, and a shared test module that runs on JVM for domain logic. For platform-specific tests, we keep them in the respective platform modules. If you do not have a CI pipeline that can run both JVM and device tests, start with JVM tests for shared code—they catch most logic errors and run in seconds.
Core Workflow: Building a Repository with Dependency Inversion
The central pattern we advocate is the repository pattern with dependency inversion. Instead of having a shared repository that directly calls Ktor or SQLDelight, we define an interface in the domain layer and implement it in the data layer. The domain layer never imports a platform-specific library. Here is the step-by-step workflow.
Step 1: Define Domain Interfaces
In the shared module's domain package, create an interface for each data source. For example, a UserRepository might have methods like getUser(id: String): Flow
Step 2: Implement in the Data Layer
In the data package (still shared module), create concrete implementations that use Ktor for network calls and SQLDelight for local storage. These implementations are not expect/actual—they are pure shared code that depends on multiplatform libraries. The key is that the implementation is injected into the domain layer via a DI module. This way, if you want to swap Ktor for a different client later, you only change the data layer.
Step 3: Provide Platform-Specific Implementations Where Needed
Some data sources inherently depend on platform APIs, such as camera, sensors, or keychain storage. For those, we use expect/actual declarations in the data layer, but we keep the interface in the domain layer. For example, a KeyValueStorage interface in domain, with expect class KeyValueStorageFactory in data, and actual implementations on each platform. The rest of the shared code never sees the expect/actual—it only knows the interface.
Step 4: Wire Dependencies in a Common DI Module
Create a shared DI module that provides all domain interfaces with their data implementations. On each platform, you may override some bindings for platform-specific variants (e.g., a different logging implementation). The platform app then starts the DI container and injects the shared module into its view models.
This workflow ensures that your domain logic is testable on JVM without any platform mocking. It also makes it easy to add new platforms (like web or desktop) because the domain layer has zero platform dependencies.
Tools, Setup, and Environment Realities
Even with a solid architecture, the development environment can introduce friction. Here are some realities we have encountered and how to handle them.
Gradle Configuration for Large Modules
As your shared module grows, Gradle build times can balloon. We recommend splitting the shared module into multiple source sets: shared-domain, shared-data, shared-test. This allows incremental compilation and parallel builds. Use Gradle's build cache and consider using the Kotlin/Native compiler cache for iOS targets. Another tip: avoid recompiling the entire shared module for every small change by using proper module boundaries and API surfaces.
iOS Integration with Xcode
The KMP framework for iOS is a static or dynamic framework that you embed in your Xcode project. We have found that using the Kotlin/Native framework with a thin Swift wrapper (via SKIE or manual bridging) works best. The wrapper handles Kotlin-specific types like Flow and sealed classes, converting them to Swift-friendly publishers and enums. Without a wrapper, your Swift code ends up littered with unsafe casts and callback blocks.
Continuous Integration
Setting up CI for KMP is more complex than for a single-platform project. We use a matrix of jobs: one for JVM tests (fast), one for Android instrumented tests (slower), and one for iOS tests (requires macOS runner). To avoid redundant builds, we cache the Kotlin/Native compiler and Gradle dependencies. We also run a linting step on the shared module to catch expect/actual mismatches early.
Variations for Different Constraints
Not every project needs the full clean architecture. Here are three common variations and when to use them.
Variation 1: Thin Shared Module (Prototype or Small Team)
If you are building a prototype or have a very small team (1-2 developers), a thin shared module that contains only pure utility functions and data models may be sufficient. In this case, you can skip the repository pattern and use expect/actual directly for network calls and storage. The downside is that you will have to rewrite the shared module if the app grows. We recommend this only for throwaway prototypes or apps with a planned lifespan under six months.
Variation 2: Feature-Based Modules (Medium to Large Team)
For teams of 3-8 developers, we recommend organizing the shared module by feature rather than by layer. Each feature has its own domain, data, and presentation packages. This allows teams to work independently on different features without merge conflicts. Dependency inversion still applies within each feature. The challenge is managing cross-feature dependencies (e.g., a user profile feature needs the authentication feature). We solve this by having a core module that provides shared interfaces and models.
Variation 3: Full Clean Architecture with Multi-Module (Enterprise)
For large teams (10+ developers) or apps with complex business logic, we use a multi-module Gradle project with separate modules for domain, data, network, database, and each feature. This gives maximum separation and allows teams to own modules independently. The trade-off is increased build complexity and a steeper learning curve for new developers. We only recommend this if you have a dedicated platform team and a strong CI/CD pipeline.
Pitfalls, Debugging, and What to Check When It Fails
Even with careful design, things go wrong. Here are the most common pitfalls we have encountered and how to debug them.
Pitfall 1: Expect/Actual Mismatches
The most frequent compile-time error is an expect declaration that is not matched by an actual on one platform. This often happens when you add a new expect function in the shared module but forget to implement it on iOS. The error message from Kotlin/Native can be cryptic. We mitigate this by running a Gradle task that checks all expect/actual pairs before committing. We also keep a small test in each platform module that instantiates every expect class to ensure they compile.
Pitfall 2: Threading Mismatches
Kotlin coroutines run on the main dispatcher by default on Android, but on iOS you must be careful about which thread dispatches to the UI. A common crash is calling a suspend function from a background thread and then updating the UI from the same coroutine. The fix is to use withContext(Dispatchers.Main) explicitly when updating UI, or use a state holder that ensures main-thread delivery. We also use a custom dispatcher for iOS that maps to the main run loop.
Pitfall 3: Memory Leaks from Flow Collection
Collecting a Flow in a ViewModel or SwiftUI view without proper cancellation can cause memory leaks. On Android, we use viewModelScope.launch; on iOS, we use a lifecycle-aware collector that cancels when the view disappears. We have seen teams forget to cancel collection in SwiftUI, leading to retained views and crashes. We recommend using a dedicated state holder class that manages collection lifecycle and exposes a simple state property.
Debugging Tips
When something breaks in the shared module, first isolate whether it is a compile-time issue (expect/actual, missing dependency) or a runtime issue (threading, null pointer). For runtime issues, we add logging using a multiplatform logging library (e.g., Napier) that outputs to both Android Logcat and Apple's os_log. We also run the shared module's unit tests on JVM first—if they pass, the bug is likely in the platform integration layer.
FAQ: Common Questions About KMP Integration Patterns
Q: Should I use expect/actual for everything?
A: No. Use expect/actual only for APIs that are inherently platform-specific (sensors, keychain, camera). For everything else (networking, databases, serialization), use multiplatform libraries and keep the code shared. Overusing expect/actual creates a maintenance burden.
Q: How do I handle platform-specific UI patterns like navigation?
A: We keep navigation entirely on the platform side. The shared module exposes state that the platform observes (e.g., a navigation event sealed class), and the platform handles the actual navigation. This avoids coupling shared code to any particular navigation library.
Q: Can I use SwiftUI previews with KMP?
A: Yes, but you need to provide mock data from the shared module. We create a mock implementation of each repository interface that returns sample data. The SwiftUI preview then uses a DI container with the mock implementation. This works well for UI development without running the full app.
Q: How do I handle background tasks on iOS?
A: Use Apple's BGTaskScheduler for background work. The shared module can expose a suspend function for the task logic, and the iOS app calls it from a BGTask. Be careful about app extensions—they have limited memory and cannot use the full Kotlin runtime. In that case, keep the logic minimal and native.
What to Do Next: Specific Actions for Your KMP Project
If you are convinced that better integration patterns will help your project, here are three concrete next steps.
1. Audit your current expect/actual usage. Open your shared module and list every expect declaration. For each one, ask: could this be replaced by a multiplatform library? If yes, refactor it. If not, ensure the actual implementations are tested on both platforms.
2. Introduce dependency injection. Even if you start with a simple manual DI container (a factory object), it will pay off. We recommend Koin because it is multiplatform and has minimal boilerplate. Create a module that provides your repositories, and inject them into your view models.
3. Write a JVM-only test for a critical use case. Pick a use case that currently requires a device to test. Refactor it to depend only on interfaces, then write a unit test with a mock repository. This will prove that your architecture is testable and give you confidence to refactor other parts.
The patterns we have described are not silver bullets—they require discipline and upfront investment. But in our experience, they reduce integration pain significantly as the project scales. Start small, iterate, and keep the shared module as a well-defined core rather than a dumping ground for cross-platform code.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!