Skip to main content
Kotlin Multiplatform Patterns

Exploring Kotlin Multiplatform Patterns: A Qualitative Guide to Architectural Craft

Kotlin Multiplatform (KMP) promises code sharing across Android, iOS, and beyond—but the promise only holds if you choose the right architectural patterns. Without deliberate structure, shared modules turn into a dumping ground for business logic that's neither truly portable nor testable. This guide is for teams who have decided to adopt KMP and now need to decide how to organize their code. We'll walk through the patterns that survive real-world pressure: how to separate concerns across platforms, when to use expect/actual versus interfaces, and how to keep your shared logic independent of framework quirks. The goal is not to prescribe a single architecture but to give you the trade-offs so you can choose what fits your team's constraints.

Kotlin Multiplatform (KMP) promises code sharing across Android, iOS, and beyond—but the promise only holds if you choose the right architectural patterns. Without deliberate structure, shared modules turn into a dumping ground for business logic that's neither truly portable nor testable. This guide is for teams who have decided to adopt KMP and now need to decide how to organize their code. We'll walk through the patterns that survive real-world pressure: how to separate concerns across platforms, when to use expect/actual versus interfaces, and how to keep your shared logic independent of framework quirks. The goal is not to prescribe a single architecture but to give you the trade-offs so you can choose what fits your team's constraints.

Who Needs This and What Goes Wrong Without It

If you're building a mobile app that targets both Android and iOS, and you've grown tired of duplicating data models, validation rules, and network calls, KMP is a natural candidate. But the teams that struggle are often the ones that treat KMP as a drop-in replacement for platform-specific code without rethinking their architecture. The most common failure we see is a shared module that balloons into a monolith: it contains database logic, HTTP clients, caching strategies, and UI state—all tangled together. When a platform-specific bug arises, developers resort to adding expect/actual declarations for everything, defeating the purpose of sharing.

Another common pitfall is ignoring the threading model. On Android, coroutines are well-supported; on iOS, the story is more nuanced. Teams that assume a single dispatcher works everywhere end up with race conditions or main-thread violations on one platform. The architecture must account for these differences from the start, not as an afterthought.

We've also seen projects where the shared code is designed around a specific framework (like Jetpack Compose or SwiftUI), making it impossible to reuse logic across platforms without heavy abstraction layers. The architecture should be framework-agnostic in the shared layer, with platform-specific adapters at the edges. Without this separation, every UI change ripples through the shared code, creating a maintenance burden that rivals the duplication you were trying to avoid.

In short, the teams that succeed with KMP are those that invest in a clear boundary between shared business logic and platform-specific presentation. They treat expect/actual as a last resort, not a first instinct. They test the shared code independently. And they accept that some code—especially UI—will remain platform-specific. The rest of this guide will help you build that boundary.

Prerequisites and Context: What You Should Settle First

Before you start writing shared code, you need to make decisions about the project structure, dependency injection, and testing strategy. These choices will shape every pattern you adopt. Let's walk through the key considerations.

Project Structure: Single Module vs. Multi-Module

The simplest setup is a single shared module with a few source sets: commonMain, androidMain, iosMain. This works for small projects, but as the codebase grows, you'll want to split the shared code into feature modules, core modules, and data modules. A multi-module setup enforces dependency rules and reduces compile times. However, it adds complexity to the build configuration, especially when you need to share modules across platforms. We recommend starting with a single shared module and extracting modules only when you have a clear boundary (e.g., networking, database, analytics).

Dependency Injection: Manual or Framework?

KMP supports several DI frameworks: Koin, Kodein, and the upcoming Kotlin-inject. Manual DI is also viable for small projects. The choice affects how you compose your architecture. If you use a framework, you can inject platform-specific dependencies (like database drivers or HTTP clients) through the common code, which keeps the shared layer clean. But beware of over-engineering: a simple factory function often suffices for platform-specific singletons.

Testing Strategy: Shared Tests and Platform Tests

One of KMP's strengths is that you can write unit tests in commonMain and run them on both platforms. But integration tests often require platform-specific setups. Plan your test suites early: pure logic tests go into commonTest, while tests that depend on platform APIs (like file I/O or network) live in platform-specific source sets. Use interfaces to mock platform dependencies in common tests.

Coroutine Scopes and Dispatchers

On Android, you typically use Dispatchers.Main for UI and Dispatchers.IO for network calls. On iOS, the main dispatcher is not available by default; you need to provide a custom dispatcher that runs on the main run loop. A common pattern is to define a dispatcher provider interface in commonMain and provide platform-specific implementations. This avoids hardcoding dispatchers in your shared code, making it testable and portable.

Core Workflow: Building a Feature with Clean Architecture

Let's walk through building a typical feature—say, a login screen—using a clean architecture approach. We'll use layers: domain (use cases, entities), data (repositories, data sources), and presentation (view models, UI state). The shared module contains the domain and data layers, while the presentation layer is platform-specific but consumes shared view models.

Step 1: Define the Domain Layer

Start with a use case class that encapsulates the login logic. It takes a repository interface as a constructor parameter. The use case returns a Result type (or a sealed class) representing success or failure. This class is pure Kotlin, with no platform dependencies. For example:

class LoginUseCase(private val authRepository: AuthRepository) {
    suspend operator fun invoke(email: String, password: String): Result<User> {
        // validation and network call
    }
}

Step 2: Implement the Data Layer

The AuthRepository interface is defined in commonMain. Its implementation, AuthRepositoryImpl, lives in the data module. It uses Ktor for HTTP calls and expects a platform-specific database driver for caching. The implementation is still in commonMain, but it depends on interfaces for the database and HTTP client. The actual Ktor engine and database driver are provided via DI.

Step 3: Create a ViewModel in CommonMain

You can define a common ViewModel base class that uses coroutines and exposes a StateFlow. The ViewModel takes the use case as a parameter and exposes UI state as a sealed class. This ViewModel is shared across platforms, but it must be platform-agnostic—no references to Android's ViewModel class or iOS's ObservableObject. Instead, we use a plain class that manages its own coroutine scope.

Step 4: Platform-Specific UI Binding

On Android, you create an Activity or Fragment that observes the shared ViewModel's StateFlow. On iOS, you create a SwiftUI view that bridges to the shared ViewModel via a wrapper class that conforms to ObservableObject. The bridge class collects the StateFlow and publishes changes to SwiftUI. This keeps the shared ViewModel pure and testable.

Step 5: Test the Shared Logic

Write unit tests for the use case and ViewModel in commonTest. Mock the repository interface and verify the state transitions. These tests run on both JVM and iOS (via Kotlin/Native test runner). You'll catch logic errors early without needing device emulators.

Tools, Setup, and Environment Realities

Setting up a KMP project involves more than just installing the plugin. You need to configure the build for each target, manage dependencies, and handle platform-specific quirks. Here's what we've learned from production projects.

Build Configuration

Use the Kotlin Multiplatform Gradle plugin. Define your targets in the build.gradle.kts file. For Android, you need the Android Gradle plugin. For iOS, you configure the framework export. The key is to keep the shared module's dependencies minimal—only include libraries that support all targets (like Ktor, kotlinx.serialization, kotlinx.coroutines). Avoid platform-specific libraries in commonMain.

Dependency Management with Version Catalogs

Use Gradle version catalogs (libs.versions.toml) to centralize dependency versions. This is especially important in multi-module projects to avoid version conflicts. You can define a set of common dependencies and platform-specific dependencies separately.

Working with Xcode and CocoaPods

For iOS, you can integrate the shared framework via CocoaPods, SPM, or direct framework embedding. CocoaPods is the most mature option, but it adds a dependency on Ruby. Direct framework embedding is simpler but requires you to run a Gradle task before building the Xcode project. We recommend using a Gradle task that builds the framework and then integrating it via a script build phase in Xcode.

Handling Platform-Specific APIs with expect/actual

Use expect/actual sparingly. Prefer interfaces that are implemented in platform-specific source sets. For example, instead of expect fun getDeviceId(): String, define an interface DeviceInfoProvider with a method getDeviceId(), and provide implementations in androidMain and iosMain. This makes the code more testable and easier to mock.

Continuous Integration

Set up CI to run both Android and iOS tests. For iOS, you need a macOS runner. You can use GitHub Actions with a self-hosted macOS runner or use a service like Bitrise. The CI should build the shared framework and run common tests, then build the Android app and run platform-specific tests.

Variations for Different Constraints

Not every project fits the clean architecture mold. Here are common variations based on team size, app complexity, and platform parity requirements.

Small Team, Simple App: Single Module with MVP

If you have a two-person team building a utility app, a single shared module with MVP (Model-View-Presenter) can be effective. The Presenter is a class in commonMain that takes a View interface (defined in commonMain) and updates it. The Android and iOS views implement the View interface. This pattern is simple to understand and requires minimal abstraction. The downside is that the View interface can become bloated with UI methods.

Large Team, Complex App: Multi-Module with MVI

For a team of ten or more working on a feature-rich app, MVI (Model-View-Intent) provides a strict unidirectional data flow. Each feature has its own module with a sealed class for intents (user actions) and a sealed class for states. The reducer function is pure and testable. This pattern scales well because each feature is isolated, and the state management is predictable. The trade-off is boilerplate: you need to define intents, states, and reducers for every feature.

Mixed Platform Parity: Shared ViewModels with Platform-Specific UI

When the Android and iOS UIs differ significantly (e.g., Android uses Material Design, iOS uses HIG), sharing the ViewModel but not the UI is a good middle ground. The ViewModel exposes StateFlow, and each platform observes it and renders its own UI. This avoids the complexity of sharing UI code while still sharing business logic. The challenge is ensuring that the ViewModel's state is consistent across platforms, especially when one platform triggers side effects that the other doesn't.

Legacy Codebase: Wrapping Existing Platform Code

If you're migrating an existing app, you might not be able to rewrite everything. In that case, use KMP for new features and gradually extract shared logic. Create a shared module that contains only the new business logic, and call it from the existing platform code via a bridge. Over time, you can move more code into the shared module. This incremental approach reduces risk but can lead to a messy architecture if not planned carefully.

Pitfalls, Debugging, and What to Check When It Fails

Even with a solid architecture, KMP projects hit snags. Here are the most common issues and how to diagnose them.

CoroutineScope Leaks on iOS

On iOS, if you create a CoroutineScope in a ViewModel and don't cancel it when the ViewModel is deallocated, you'll leak memory. Use a WeakReference to the scope or explicitly cancel in the deinit. Debug by checking memory allocations in Xcode's Instruments.

Serialization Incompatibility

Kotlinx.serialization works on both platforms, but some data types (like Date) require custom serializers. If you get a runtime error about missing serializer, check that you've annotated all classes with @Serializable and provided custom serializers for platform-specific types.

Expect/Actual Mismatch

If you declare an expect function in commonMain but forget to provide the actual implementation in one platform, you'll get a linker error. This is easy to miss when adding a new platform target. Use the Kotlin compiler's expect/actual checker: it will warn you about missing actuals. Also, run builds for all targets regularly.

Threading Issues on iOS

By default, Kotlin/Native dispatches coroutines on a background thread. If you update the UI from a coroutine, you need to switch to the main dispatcher. On iOS, the main dispatcher is not automatic; you must provide it. Use a helper function that dispatches to the main run loop. Test by adding assertions that UI updates happen on the main thread.

Build Cache Problems

Gradle's build cache can cause stale artifacts when switching between platforms. If you see strange errors after changing platform-specific code, clean the build cache (./gradlew clean) and rebuild. This is especially common when you have both Android and iOS builds in the same project.

What to Check When the App Crashes on Startup

If the app crashes immediately, check the framework integration. On iOS, ensure the framework is copied into the app bundle and that the binary is properly linked. On Android, check that the shared module is included in the dependencies and that the minSdkVersion is compatible. Use the Android Studio profiler and Xcode's crash logs to pinpoint the issue.

Finally, remember that KMP is still evolving. The tooling improves with each release, but you'll encounter edge cases. Join the community forums and check the official issue tracker before spending hours debugging. And always write tests for the shared code—they'll catch regressions when you upgrade Kotlin versions.

Share this article:

Comments (0)

No comments yet. Be the first to comment!