Skip to main content
Kotlin Multiplatform Patterns

Kotlin Multiplatform Patterns: Advanced Strategies for Cross-Platform Craft and Quality at Artnest

When we talk about Kotlin Multiplatform (KMP), the conversation often starts with 'write once, run anywhere.' But anyone who has shipped a KMP project to production knows that the real craft lies in the patterns you choose for sharing code without sacrificing platform quality. At Artnest, we've seen teams move from excited experimentation to painful re-architecture when they overlook the structural decisions that make or break a multiplatform codebase. This guide walks through advanced strategies that treat KMP not as a magic abstraction but as a careful trade-off between shared logic and native excellence. Where These Patterns Show Up in Real Work The first place teams encounter KMP's complexity is in the shared module's boundary with platform code. A typical project starts with a core domain layer—entities, use cases, and repository interfaces.

When we talk about Kotlin Multiplatform (KMP), the conversation often starts with 'write once, run anywhere.' But anyone who has shipped a KMP project to production knows that the real craft lies in the patterns you choose for sharing code without sacrificing platform quality. At Artnest, we've seen teams move from excited experimentation to painful re-architecture when they overlook the structural decisions that make or break a multiplatform codebase. This guide walks through advanced strategies that treat KMP not as a magic abstraction but as a careful trade-off between shared logic and native excellence.

Where These Patterns Show Up in Real Work

The first place teams encounter KMP's complexity is in the shared module's boundary with platform code. A typical project starts with a core domain layer—entities, use cases, and repository interfaces. The temptation is to push everything into commonMain, but real-world constraints like threading models, file I/O, and UI frameworks force you to draw lines. For example, consider an app that needs to cache data locally. On Android, you might reach for Room; on iOS, Core Data or SwiftData. A naive approach would be to define a generic cache interface in commonMain and implement it twice. That works, but the pattern that scales better is to use expect/actual for the data access layer while keeping the business logic that orchestrates caching in shared code. We've seen teams in mid-size projects (two to four platforms) adopt this pattern with a repository that delegates to platform-specific storage implementations. The key insight is that the 'what' (cache invalidation rules, offline-first logic) stays shared, while the 'how' (SQLite vs. Core Data) stays native.

Another common scenario is networking. Ktor client is the go-to for multiplatform HTTP, but its configuration—especially SSL certificates, cookie storage, and background uploads—differs per platform. A pattern we recommend is to build a thin network service in commonMain that uses expect/actual for the engine and for platform-specific interceptors, like logging or auth token injection. This keeps the request/response handling unified while allowing each platform to manage its own security context. Teams that skip this separation often find themselves fighting with serialization quirks or threading deadlocks when they try to force everything into a single expect/actual file.

Composite Scenario: A Social Feed App

Imagine a social feed app that must work on Android, iOS, and desktop. The feed logic—fetching posts, pagination, local caching, and reaction handling—is a perfect candidate for sharing. But the image loading and UI layout are not. A successful pattern we've observed is to define a FeedRepository in commonMain that returns a reactive stream of Post objects. Each platform then has its own ViewModel or Presenter that subscribes to this stream and maps the data to native UI components. The repository uses an expect/actual ImageCache interface: Android uses Coil, iOS uses Kingfisher, and desktop uses a simple file-based cache. This pattern avoids the common mistake of trying to abstract UI components, which often leads to leaky abstractions and poor performance.

Foundations Readers Confuse

One of the most persistent misunderstandings is the role of expect/actual. Newcomers often treat it as a one-to-one mapping for every platform difference, leading to a proliferation of actual declarations that mirror the entire platform API. In practice, expect/actual should be reserved for essential differences that cannot be expressed through interfaces or dependency injection. For example, using expect/actual to get the current platform's locale is reasonable; using it to wrap every Android-specific API call is not. A better foundation is to design your shared module around interfaces and sealed classes, then provide platform-specific implementations via a dependency injection framework like Koin or Kodein. This keeps your commonMain testable and your actual files minimal.

Another confusion is around serialization. kotlinx.serialization works well across platforms, but its polymorphic handling can trip teams up. We've seen projects where a shared data class uses a sealed class hierarchy for network responses, but the serialization configuration differs between platforms because of how each handles unknown enum values. The pattern that works is to define a central serialization module in commonMain with a strict schema and a custom serializer that handles missing fields gracefully, then export that module to each platform. Avoid the temptation to define serializers in actual blocks—that almost always leads to deserialization mismatches in production.

Common Pitfall: Over-Abstraction

Teams that come from a Java or .NET background sometimes try to create a full abstraction layer over platform APIs, like a multiplatform file system or a multiplatform UI toolkit. In KMP, this is almost always a mistake. The platform SDKs are too rich and too different to be fully abstracted without losing performance or developer ergonomics. Instead, keep your shared code focused on business rules, data flow, and state management. Leave UI and platform-specific APIs to native code, and use KMP as a tool for consistency in the middle layer.

Patterns That Usually Work

Through trial and error across several KMP projects, we've identified a handful of patterns that consistently reduce friction. The first is the Repository + UseCase pattern, where all data access goes through a repository interface defined in commonMain, and use cases orchestrate business logic without knowing whether the data comes from a network or a cache. This pattern works because it keeps the shared module testable and allows each platform to swap in its own data sources without affecting the rest of the app.

The second is the Sealed Class for UI State pattern. Define a sealed class or a generic UiState<T> in commonMain that represents loading, success, error, and idle states. Each platform's ViewModel or Presenter maps this to its own reactive framework (LiveData, StateFlow, ObservableObject). This pattern eliminates duplication of state-handling logic and ensures consistent error messaging across platforms.

Third is the expect/actual for Keychain/Keystore pattern. Secure storage is inherently platform-specific, but its API surface is small. Define an expect class SecureStorage with methods like save(key, value) and get(key), then provide actual implementations using Android Keystore and iOS Keychain. This pattern is lightweight and avoids pulling in third-party libraries that may not be well-maintained on all platforms.

Decision Criteria for Each Pattern

Use the Repository pattern when you have multiple data sources or want to unit test business logic without real network calls. Use the UiState pattern when your app has at least three screens that share loading/error handling. Use SecureStorage expect/actual when you need to store tokens or sensitive data—but only if you cannot use a multiplatform library like Multiplatform Settings. If you need encryption beyond simple key-value storage, consider a dedicated library instead of rolling your own expect/actual.

Anti-Patterns and Why Teams Revert

One of the most common anti-patterns is the Giant expect/actual object. Teams create a single expect class with dozens of methods that wrap platform APIs, then fill actual files with massive implementations. This approach breaks down because it couples every platform change to a recompile of the shared module, and it makes testing nearly impossible. The better alternative is to split platform-specific logic into small, focused interfaces or expect declarations, each with a single responsibility.

Another anti-pattern is sharing UI code via Compose Multiplatform for every screen. While Compose Multiplatform is powerful, it is not a silver bullet. For screens that require complex native interactions (like maps, camera, or AR), forcing Compose often leads to performance issues and limited access to platform APIs. We've seen teams revert to native UI for those screens, keeping Compose only for simple list-detail or settings screens. The rule of thumb: if a screen uses more than two platform-specific components, implement it natively and share only the data layer.

A third anti-pattern is ignoring concurrency differences. Kotlin coroutines work on all platforms, but iOS's main thread is not the same as Android's. Teams that assume Dispatchers.Main behaves identically on iOS often encounter freezes or crashes. The fix is to always test on real devices and to use Dispatchers.Main only for UI updates, not for heavy computation. On iOS, consider using Dispatchers.Default for background work and dispatching to the main thread explicitly via withContext.

Why Teams Revert to Separate Codebases

When anti-patterns accumulate, the cost of maintaining the shared module can exceed the benefits. Teams often revert to separate codebases when they find themselves writing more actual declarations than shared code, or when the build system becomes a bottleneck. To avoid this, keep the shared module small and enforce strict code review rules that prevent platform-specific logic from leaking into commonMain. A good target is 60-70% shared code for a two-platform app; if you dip below 50%, reconsider your architecture.

Maintenance, Drift, and Long-Term Costs

Over time, KMP projects face a natural drift between platforms. The Android team might adopt a new API that the iOS team does not need, and the shared module becomes a negotiation point. The cost of maintaining alignment is real: every expect/actual declaration is a contract that both platforms must honor. If one platform's actual implementation falls behind, the shared module's tests may pass on one platform but fail on another. This is especially tricky for serialization and network code, where a change in the backend response can break one platform's parsing while leaving the other intact.

To manage drift, we recommend a shared contract test suite that runs on every platform during CI. These tests should cover the core data flow: repository calls, state transitions, and serialization. If a test fails on one platform but not another, the team can quickly identify whether the issue is in the actual implementation or in the shared code. Another practice is to version the shared module independently of the app, so that each platform can update at its own pace. This adds some overhead but prevents a single platform's delay from blocking the others.

Long-Term Cost: Library Compatibility

Third-party KMP libraries are still maturing. A library that works today may not support the latest Kotlin version tomorrow, or it may drop iOS support without notice. Teams that depend heavily on such libraries (for database, DI, or networking) face migration costs when those libraries become unmaintained. Mitigate this by wrapping external libraries behind your own interfaces, so you can swap them out without touching business logic. Also, prefer libraries with a proven track record and an active community.

When Not to Use This Approach

KMP is not the right tool for every project. If your app is a prototype or a short-lived campaign, the overhead of setting up a multiplatform build pipeline may not be worth it. Similarly, if your team has deep expertise in one platform and little in the other, you may find that the shared module becomes a bottleneck because both platforms need to understand the same code. In that case, it might be faster to build two separate apps initially and consider KMP only when you have a stable product and a cross-platform team.

Another situation to avoid KMP is when your app relies heavily on platform-specific hardware features, like Bluetooth, NFC, or ARKit/ARCore. While you can abstract some of this with expect/actual, the abstraction often leaks, and you end up maintaining more code than if you had kept the feature native. For apps where 80% of the logic is UI and platform-specific, KMP adds complexity without much benefit. Reserve KMP for data-heavy, logic-intensive apps where the shared code can be a significant portion of the total codebase.

Composite Scenario: A Calculator App

Consider a simple calculator app. The business logic (parsing expressions, evaluating results) is trivial and can be shared, but the UI is simple and platform-specific tooling (like Android's Jetpack Compose and iOS's SwiftUI) makes it easy to build quickly. In this case, the cost of setting up KMP outweighs the benefit. A better candidate is an app that manages complex state, like a project management tool with offline sync, push notifications, and real-time collaboration. That's where KMP shines.

Open Questions and FAQ

How do we handle platform-specific UI patterns like navigation?

Navigation is best left to each platform. Shared code should not try to abstract navigation, because Android's Jetpack Navigation and iOS's NavigationStack have fundamentally different paradigms. Instead, have each platform's ViewModel emit a navigation event (a sealed class) that the native UI layer interprets. This keeps the shared module unaware of how navigation is implemented.

Can we use KMP with existing native libraries?

Yes, but with caution. You can call native libraries from commonMain via expect/actual, but the actual implementation must bridge to the native API. For example, you can use an expect class to wrap a C library, then provide actual implementations that call the library via JNI on Android and cinterop on iOS. This works well for small, stable libraries but becomes cumbersome for large ones. Consider wrapping only the functions you need.

What is the state of KMP testing?

Testing in commonMain is straightforward with kotlin.test. You can write unit tests for business logic that run on all platforms. Integration tests that require platform-specific behavior (like database or network) are harder. We recommend using a test-only expect/actual to provide mock implementations on each platform, or using a multiplatform testing library like Ktor's MockEngine for networking.

How do we handle dependency injection across platforms?

Koin and Kodein both support KMP. The pattern is to define your modules in commonMain with platform-specific bindings provided via expect/actual or by passing a platform context. For example, you might define a PlatformModule expect class that returns a list of platform-specific dependencies, and then each platform provides its own actual implementation. This keeps your DI configuration centralized while allowing platform differences.

Summary and Next Experiments

The patterns we've discussed—Repository + UseCase, Sealed Class for UI State, and focused expect/actual for secure storage—form a solid foundation for KMP projects. The key is to resist over-abstraction and to keep the shared module focused on business logic that truly benefits from cross-platform reuse. Start with a small shared module (data models, networking, and state management) and expand only when you have proven that the pattern works on all target platforms.

For your next experiment, try refactoring an existing app's data layer into a KMP shared module. Measure the code duplication reduction and the time spent on platform-specific bug fixes. You'll likely find that the initial investment pays off after a few releases. Also, consider contributing to the KMP ecosystem by sharing your own expect/actual wrappers for common platform APIs—the community grows stronger with every well-designed pattern.

Finally, stay pragmatic. KMP is a tool, not a religion. If a particular screen or feature is causing more pain than benefit in shared code, move it to native. The goal is not 100% code sharing; it's consistent quality and faster iteration across platforms. With the right patterns, you can achieve both.

Share this article:

Comments (0)

No comments yet. Be the first to comment!