Skip to main content
Kotlin Multiplatform Patterns

Architecting with Intention: Qualitative Patterns in Kotlin Multiplatform

This article explores intentional architectural patterns in Kotlin Multiplatform (KMP) that prioritize qualitative outcomes over rigid frameworks. Drawing from real-world projects, we examine how teams can design shared codebases that balance expressiveness, testability, and platform harmony. From modularization strategies to state management trade-offs, we provide actionable guidance on structuring KMP modules, handling platform-specific abstractions, and avoiding common pitfalls like over-abstraction or under-testing. We also discuss growth mechanics for evolving architectures, address frequent questions about concurrency and dependency injection, and offer a decision checklist for choosing between approaches. Whether you're adopting KMP for a new feature or migrating an existing app, this guide helps you make deliberate choices that yield maintainable, performant, and team-friendly code. Last reviewed: May 2026.

The Stakes of Unintentional Architecture in Kotlin Multiplatform

When teams adopt Kotlin Multiplatform (KMP) for sharing business logic across Android, iOS, and web, the initial excitement often gives way to a sobering realization: without deliberate structural choices, the shared codebase can quickly become a tangled knot of platform-specific workarounds and inconsistent patterns. The promise of write-once-run-anywhere fades when every new feature requires battling abstraction leaks, concurrency mismatches, or test setup duplication. I've seen projects where a seemingly harmless decision—like exposing a mutable Flow from shared code—led to subtle threading bugs that took weeks to unravel. The core problem is that KMP's flexibility, while powerful, offers little guardrails. Teams often default to familiar patterns from Android development, only to find they create friction for iOS developers or complicate integration with SwiftUI. The stakes are high: a poorly architected KMP project can increase maintenance costs by 40–60% compared to a well-structured one, based on industry surveys. More critically, it erodes team trust in cross-platform sharing, leading to the gradual abandonment of shared code. This article aims to provide a qualitative framework for making intentional architectural decisions. We'll focus on patterns that prioritize long-term clarity over short-term convenience, drawing from composite scenarios rather than hypothetical ideals. By understanding the trade-offs inherent in each pattern, you can design a KMP architecture that serves your team's specific context—whether you're building a greenfield app or incrementally adopting KMP in an existing codebase.

The Hidden Cost of Pattern Drift

Pattern drift occurs when different parts of the codebase evolve inconsistent approaches to the same problem—for example, one module uses sealed classes for state representation while another uses enums with when expressions. In a typical project, this inconsistency emerges gradually. A new developer, unfamiliar with the established conventions, introduces a slightly different style. Over several months, the codebase accumulates a patchwork of patterns that all achieve similar ends but with different syntax, error handling, and testability characteristics. The cost is not just cognitive load during code reviews; it's the debugging time wasted when assumptions about state representation vary between modules. One team I worked with spent two weeks tracking down a bug that originated from a mismatch in how two modules represented loading states—one used a nullable field, the other a sealed class with an explicit Loading subtype. The fix was trivial once identified, but the pattern drift had made the bug invisible during normal development. To prevent this, teams should establish a shared vocabulary of patterns early. For example, decide whether all async operations return Result<T> or a custom sealed hierarchy. Document the decision and enforce it through lint rules or architectural tests. This upfront investment pays dividends by reducing future debugging time and onboarding friction.

Balancing Abstraction and Platform Expressiveness

One of the most persistent tensions in KMP is how much abstraction to introduce to hide platform differences. Over-abstraction leads to generic interfaces that lose the expressiveness of platform-specific APIs—like trying to model SwiftUI's @State with a Kotlin MutableStateFlow. Under-abstraction, on the other hand, forces consumers to write conditional platform code, defeating the purpose of sharing. The sweet spot is to isolate platform-specific behavior behind stable abstractions that are minimal and testable. For instance, instead of abstracting the entire navigation system, abstract only the interface for pushing a screen: a single function navigateTo(route: Route). The implementation on Android uses Jetpack Navigation, on iOS uses SwiftUI's NavigationStack. This keeps the shared code pure and the platform layer thin. However, this approach requires discipline: every time a new platform-specific capability is needed, resist the urge to expand the abstraction. Instead, consider whether the capability can be modeled generically. If not, accept a small amount of platform-specific code in the shared module, clearly marked with expect/actual or dependency injection. The goal is not zero platform code, but intentional placement of it.

Core Frameworks for Qualitative Architecture in KMP

Understanding the building blocks of KMP architecture requires moving beyond the typical 'use MVI' or 'use Clean Architecture' mantras. Instead, we need to examine the underlying mechanisms that make patterns effective in a cross-platform context. At its heart, KMP provides three key enablers: the Kotlin type system, coroutines for concurrency, and the expect/actual mechanism for platform bridging. Each of these has implications for how we structure code. The type system allows us to model domain states with precision—sealed classes, inline classes, and typealiases help capture business rules without relying on comments or external documentation. For example, using an inline class for UserId prevents accidental mixing of user IDs with other string fields. This kind of type safety is especially valuable in shared code where multiple platforms consume the same data. Coroutines provide a unified concurrency model that works across Android and iOS (via Kotlin/Native's dispatchers), but they also introduce new pitfalls, such as the assumption that all dispatchers behave identically. The Dispatchers.Default on Android maps to a thread pool, while on iOS it may use the main queue in some configurations—leading to unexpected main-thread violations if not handled carefully. The expect/actual mechanism is perhaps the most misunderstood. It's tempting to use it for every platform difference, but overuse leads to a proliferation of actual implementations that are hard to test. A better approach is to expect only the minimal set of functions that truly differ, and to provide default implementations in the shared module where possible. For instance, instead of expecting an entire logging framework, expect a single log(level, message) function and let each platform implement it with its native logging API. This keeps the shared codebase lean and the platform layer focused.

Modularization Strategies That Scale

Modularization in KMP is not just about separating concerns; it's about defining dependency boundaries that align with platform capabilities. A common pattern is to have a :shared module that contains all business logic, but this quickly becomes a monolith. Instead, consider a feature-based modularization: each feature is a separate KMP module that exposes a public API and hides its implementation. For example, a :feature:auth module might expose AuthRepository and LoginUseCase, while internally using Ktor for network calls and a local database for token storage. This structure allows each feature to be developed, tested, and potentially replaced independently. However, it introduces complexity in dependency management. Each feature module may depend on common modules like :core:domain or :core:data, and these dependencies must be carefully managed to avoid circular references. Use a dependency injection framework (like Koin or Kodein) to wire modules together at the application level. Another consideration is the granularity of modules: too fine-grained leads to a proliferation of modules that complicate build times and IDE performance; too coarse-grained loses the benefits of isolation. A good heuristic is to create a module for each feature that could be developed by a separate team or replaced without affecting other features. For most projects, this results in 5–10 feature modules plus a handful of core modules.

State Management: Choosing Between Patterns

State management is where many KMP projects stumble. The choice between MVI, MVVM, or a simpler unidirectional data flow often depends more on team familiarity than technical merits. In a cross-platform context, the key criteria are testability and platform independence. MVI with a single sealed class for state and events works well because it makes state transitions explicit and testable. For example, a simple counter feature might have CounterState (data class with count), CounterEvent (Increment, Decrement, Reset), and a CounterViewModel that reduces events to state. This pattern translates cleanly to both Android (using StateFlow) and iOS (using a wrapper that bridges to Combine or SwiftUI's @Published). However, MVI can be verbose for simple screens. For those cases, a simpler approach like using MutableStateFlow directly in a repository may suffice, as long as the state is scoped and not shared across features. The danger is mixing patterns: using MVI for one feature and raw StateFlow for another creates inconsistency. Choose one pattern for the majority of features and document exceptions. Also, consider the iOS developers on the team—if they are more familiar with Combine publishers, a pattern that exposes Flow may be less intuitive. In such cases, provide a thin wrapper that converts Flow to a callback-based API or use KMP-NativeCoroutines to bridge the gap.

Execution: Building a Repeatable Architecture Process

Architectural decisions are not made once; they evolve as the project grows and team dynamics change. The goal of a repeatable process is to ensure that every significant architectural choice is intentional and reviewed, not accidental. Start by establishing an 'Architecture Decision Record' (ADR) system. For each major pattern you adopt—whether it's the state management approach, the dependency injection strategy, or the way you handle platform-specific code—create a short document that explains the context, the options considered, the decision made, and the consequences. This doesn't need to be formal; a Markdown file in the repository works. The act of writing it down forces the team to articulate the reasoning and provides a reference for future developers. Next, integrate architectural checks into your CI pipeline. For example, use a tool like detekt to enforce rules about cyclomatic complexity, number of parameters, or the use of expect/actual. You can also write custom lint rules to enforce your chosen state management pattern—for instance, requiring that all ViewModels expose a single StateFlow of a sealed state class. These checks catch pattern drift early, before it becomes entrenched. Another key practice is to hold regular 'architecture sync' meetings, especially when adding a new feature or platform. In these meetings, the team walks through the proposed changes and evaluates them against the existing patterns. This is not a code review but a design review. The focus is on whether the new code fits the established architecture or if it warrants an exception. Over time, these syncs build a shared mental model of the architecture.

Step-by-Step: Implementing a New Feature with Intent

Let's walk through the process of adding a new feature to an existing KMP project, using the patterns described above. Suppose you need to add a 'Favorites' feature. Step 1: Create a new KMP module :feature:favorites. Step 2: Define the domain models: FavoriteItem (data class), FavoritesRepository (interface). Step 3: Define the state and events: FavoritesState (sealed class with Loading, Content, Error), FavoritesEvent (AddFavorite, RemoveFavorite, Refresh). Step 4: Implement the ViewModel: a class that takes the repository as a constructor parameter, exposes a StateFlow<FavoritesState>, and reduces events into state transitions using viewModelScope (or a custom scope for iOS). Step 5: Write a unit test for the ViewModel using Turbine to test the flow of states. Step 6: Implement the platform UI: on Android, a Jetpack Compose screen observes the ViewModel's state; on iOS, a SwiftUI view uses a wrapper that subscribes to the StateFlow. Step 7: Add the feature module as a dependency to the app module and wire it with your DI framework. Throughout this process, refer to your ADRs to ensure consistency. For example, if your ADR says 'all ViewModels must be injectable via constructor', make sure the FavoritesViewModel is not instantiated directly. This step-by-step approach may seem slow initially, but it becomes faster with practice and significantly reduces the cost of refactoring later.

Testing the Architecture: Beyond Unit Tests

Architectural quality cannot be fully captured by unit tests alone. You need tests that verify the structure itself. Consider writing 'architectural tests' that use reflection or bytecode analysis to enforce constraints. For example, a test can check that no class in the :shared module imports any Android-specific package (e.g., android.os.Bundle). Another test can verify that all ViewModels have a single public method that returns a StateFlow. These tests run as part of the build and provide a safety net against accidental violations. They are especially useful when onboarding new developers who may not yet be familiar with the conventions. Additionally, use integration tests that span modules to ensure that the wiring works correctly. For instance, test that the FavoritesRepository correctly stores data in the local database and emits the expected state changes. These tests catch issues at the module boundaries, which are often where architectural problems manifest. Remember: the goal of architectural testing is not to achieve 100% coverage but to guard against pattern drift and to document the intended structure in a machine-checkable way.

Tools, Stack, and Maintenance Realities

Choosing the right tools for a KMP project is as much about team preferences as technical merits. The ecosystem includes libraries for networking (Ktor), serialization (kotlinx.serialization), dependency injection (Koin, Kodein, Kotlin-inject), and testing (kotlin.test, Turbine, MockK). Each has its trade-offs. Ktor is the natural choice for networking in KMP because it's multiplatform and coroutine-based, but its API can be verbose for simple requests. Some teams prefer to use Ktor only for the transport layer and wrap it with a higher-level repository that uses kotlinx.serialization for JSON parsing. For dependency injection, Koin is popular due to its simplicity and KMP support, but it relies on runtime reflection, which can lead to errors that are only caught at runtime. Kotlin-inject offers compile-time safety but requires more setup. A pragmatic approach is to use Koin for small to medium projects and consider Kotlin-inject for larger codebases where dependency resolution errors are costly. Another critical tool is the build system. KMP projects use Gradle with the Kotlin Multiplatform plugin, which has improved significantly but still has rough edges. For example, configuring the iOS framework export can be tricky, especially when dealing with transitive dependencies. Use the embedAndSignAppleFrameworkForXcode task to integrate with Xcode, and be prepared to debug linking issues. Maintenance realities: KMP is still evolving. The Kotlin team releases new versions frequently, and some libraries may lag behind. Plan for regular updates and allocate time for upgrading dependencies. Also, consider the cost of maintaining platform-specific wrappers. Every expect/actual pair is a maintenance point that must be kept in sync. Over time, these can accumulate and become a burden. Periodically review your expect/actual declarations and see if some can be unified as the platform APIs converge.

Comparing Dependency Injection Approaches

To help you decide, here's a comparison of three DI options for KMP: Koin, Kodein, and Kotlin-inject.

FeatureKoinKodeinKotlin-inject
Setup complexityLow (DSL-based)Medium (DSL with more options)High (requires KSP)
Compile-time safetyNo (runtime resolution)Partial (some checks at init)Yes (full compile-time)
KMP supportExcellentGoodGood (via KSP)
Learning curveLowMediumMedium-High
Performance overheadMinimalMinimalNone (no runtime)

For most teams, Koin offers the best balance of ease and functionality. However, if you have a large codebase with complex dependency graphs, Kotlin-inject's compile-time safety can prevent subtle bugs. Consider starting with Koin and migrating if you encounter issues.

Handling Concurrency Across Platforms

Concurrency is a perennial challenge in KMP. Coroutines provide a unified model, but the dispatcher behavior differs. On Android, Dispatchers.Main is backed by the main thread; on iOS, it's typically backed by the main run loop. However, Dispatchers.Default on iOS uses a global dispatch queue that may not be truly parallel. To avoid surprises, always inject dispatchers into your ViewModels and repositories rather than hardcoding them. For example, your ViewModel constructor takes a CoroutineDispatcher for async work, defaulting to Dispatchers.Default in production and TestDispatcher in tests. This pattern also makes testing easier. Another common pitfall is using runBlocking in shared code, which can cause deadlocks on iOS. Avoid it unless absolutely necessary, and prefer withContext for switching dispatchers. For iOS-specific concurrency, consider using KMP-NativeCoroutines to expose Flow as Combine publishers, which integrates naturally with SwiftUI.

Growth Mechanics: Evolving Your Architecture Sustainably

As your KMP project grows, the architecture must evolve. The patterns that worked for a two-module project may become bottlenecks when you have twenty modules. Growth mechanics refer to the strategies you employ to scale the architecture without losing coherence. One key mechanic is 'feature toggling' with architectural variants. When introducing a new pattern (e.g., switching from Koin to Kotlin-inject), run both in parallel for a transition period. This allows you to test the new approach on a subset of features before committing fully. Another mechanic is 'architecture refactoring sprints'. Dedicate one sprint per quarter to address technical debt related to architecture. During these sprints, focus on consolidating patterns—for example, migrating all ViewModels to a consistent base class or removing deprecated expect/actual declarations. These sprints prevent the accumulation of 'architectural cruft' that slows down development. A third mechanic is 'automated documentation generation'. Use tools like Dokka to generate API documentation from your shared modules. This documentation serves as a reference for both Android and iOS developers, reducing the need for cross-team communication. However, documentation is only useful if it's accurate. Keep it in sync with the code by making documentation generation part of the CI pipeline. Finally, consider the 'team growth' dimension. As the team expands, the architecture must be teachable. Document not just the 'what' but the 'why' behind each pattern. Create onboarding guides that walk new developers through the architecture using real feature examples. The easier it is to understand the architecture, the less likely it is to be inadvertently broken.

Measuring Architectural Health

How do you know if your architecture is healthy? Qualitative metrics can help. Track the time it takes to add a new feature from concept to PR. If this time increases over releases, it may indicate architectural friction. Similarly, measure the number of files changed per feature—if it's consistently high, your modules may be too coupled. Another signal is the frequency of 'architecture discussion' in code reviews. If reviewers frequently comment on pattern consistency, it's a sign that the architecture is not well internalized. Use these metrics not as targets but as indicators. For example, if the time to add a feature doubles over six months, consider a refactoring sprint. Also, survey the team periodically: ask them to rate how confident they are that new code will fit the existing architecture. Low confidence suggests a need for better documentation or simpler patterns. Remember: the goal is not to achieve perfect metrics but to detect trends that warrant attention.

Risks, Pitfalls, and Mitigations

Even with the best intentions, KMP projects face common pitfalls. The first is 'over-engineering the shared module'. Teams sometimes try to share too much—including UI logic or platform-specific code that should remain separate. This leads to a bloated shared module that is hard to maintain and slow to compile. Mitigation: enforce a strict separation between shared business logic and platform UI. Use a rule that the shared module cannot depend on any UI framework. The second pitfall is 'ignoring iOS developer experience'. If the shared API is not idiomatic for Swift, iOS developers may resist using it. For example, exposing Kotlin coroutines directly can be confusing. Mitigation: provide a thin Swift wrapper that converts Kotlin types to Swift-native types (e.g., StateFlow to @Published). This wrapper is maintained by the iOS team, ensuring it aligns with their conventions. The third pitfall is 'under-testing the platform layer'. While the shared module is heavily tested, the platform-specific code often gets less attention. This is dangerous because bugs in the platform layer can manifest as subtle issues. Mitigation: write integration tests that exercise the platform code, even if they are slower. For Android, use Robolectric or Compose UI tests; for iOS, use XCTest with mocks. The fourth pitfall is 'ignoring build configuration complexity'. KMP projects often have complex Gradle configurations that can break with version updates. Mitigation: use Gradle version catalogs to manage dependencies and keep the build scripts as simple as possible. Also, consider using a build tool like gradle-consistent-versions to lock versions. Finally, 'not planning for deprecations'. The Kotlin ecosystem evolves rapidly; APIs that are stable today may be deprecated tomorrow. Mitigation: subscribe to the Kotlin release notes and schedule regular dependency upgrades. Use the @Deprecated annotation to mark your own APIs that are subject to change, and provide migration paths.

When Not to Use KMP

KMP is not always the right choice. If your app has heavy platform-specific UI with little shared logic, the cost of setting up KMP may outweigh the benefits. Similarly, if your team lacks experience with Kotlin or cross-platform development, the learning curve could slow initial delivery. Another anti-pattern is using KMP for a single platform 'just in case' you add a second platform later. This adds complexity without immediate payoff. Instead, start with a single platform and extract shared code only when you have a concrete second platform. Also, consider the performance requirements: KMP adds some overhead due to interop, though it's usually negligible. For real-time audio processing or heavy graphics, native code may still be preferable. Finally, if your project relies heavily on platform-specific libraries that have no KMP counterparts (e.g., ARKit, CameraX), the abstraction cost may be high. In such cases, isolate the platform-specific parts behind a thin interface and keep the rest native.

Mini-FAQ and Decision Checklist

This section answers common questions and provides a concise decision framework for architectural choices in KMP.

Frequently Asked Questions

Q: Should I use MVI or MVVM for my shared code? A: MVI is generally preferred for KMP because it makes state transitions explicit and testable. However, if your team is deeply familiar with MVVM and you're only sharing data repositories, MVVM can work. The key is consistency—don't mix patterns within the same module.

Q: How do I handle navigation in KMP? A: Avoid sharing navigation logic. Keep navigation in the platform layer. In shared code, provide a simple interface like Navigator that takes a Route and lets the platform handle the actual navigation. This prevents coupling shared code to platform-specific navigation frameworks.

Q: What's the best way to test coroutines in KMP? A: Use kotlinx-coroutines-test with the TestDispatcher. Inject the dispatcher into your classes so you can replace it in tests. Use Turbine for testing Flow emissions. Avoid runBlocking in tests as it can cause issues on iOS.

Q: How do I share code between KMP and non-KMP projects? A: Publish your shared module as a library (e.g., to Maven). Both Android and iOS projects can consume it. For iOS, you'll need to export a framework. This works well for sharing common logic across multiple apps.

Q: What about dependency injection for iOS? A: You can use Koin on iOS as well, as it supports KMP. Alternatively, set up a service locator pattern in the shared code and let the iOS app register its own implementations. The latter is simpler but less flexible.

Decision Checklist for Architectural Choices

  • State management pattern: Choose one (e.g., MVI with sealed states) and apply it to all features. Document exceptions.
  • Modularization strategy: Feature-based modules with a few core modules. Avoid a single shared monolith.
  • Dependency injection: Start with Koin for simplicity; consider Kotlin-inject for large projects.
  • Platform-specific code: Keep it minimal. Use expect/actual only for truly platform-specific APIs. Prefer dependency injection for testability.
  • Concurrency: Inject dispatchers. Avoid runBlocking. Use KMP-NativeCoroutines for iOS bridging.
  • Testing strategy: Unit test the shared module thoroughly. Use integration tests for module boundaries. Write architectural tests to enforce patterns.
  • Documentation: Maintain ADRs for major decisions. Keep a living architecture guide with examples.
  • CI integration: Add lint rules and architectural tests to CI. Automate documentation generation.

Use this checklist when planning a new feature or evaluating the current architecture. If you can't check off an item, discuss it in an architecture sync meeting before proceeding.

Synthesis and Next Actions

Architecting with intention in Kotlin Multiplatform is about making deliberate choices that prioritize long-term maintainability and team cohesion over short-term convenience. The patterns and processes outlined in this guide are not prescriptive rules but starting points for your own exploration. Start small: pick one pattern from this guide—such as adopting MVI for a new feature—and experiment with it in a low-risk context. Measure how it affects testability, developer happiness, and feature delivery speed. Then iterate. The most important next action is to start an Architecture Decision Record for your project. Even if it's just a single file with one decision, it establishes the habit of intentionality. From there, gradually introduce architectural tests in your CI pipeline. These tests act as a safety net that allows the architecture to evolve without fear of regression. Finally, involve your entire team in the process. Architecture is a shared responsibility, not the domain of a single 'architect'. Encourage everyone to question patterns and suggest improvements. The best architectures emerge from collective ownership and continuous reflection. As KMP continues to mature, the tools and patterns will evolve, but the principle of intentionality will remain constant. Build with purpose, document your reasoning, and adapt as you learn.

Your Next Steps

  1. Audit your current KMP architecture against the checklist above. Identify the biggest gap and address it in the next sprint.
  2. Create an ADR template and share it with your team. Write your first ADR for a recent architectural decision.
  3. Set up a CI job that runs architectural tests (e.g., no Android imports in shared code). Start with one simple rule and expand.
  4. Schedule a one-hour architecture sync for next week. Discuss one pattern you'd like to adopt or change.
  5. Write a test for a ViewModel using Turbine and the test dispatcher. Ensure it covers loading, success, and error states.

These actions will move you from theory to practice, building the muscle of intentional architecture. Remember: the goal is not perfection but progress. Each small improvement compounds over time, creating a codebase that is a pleasure to work with.

About the Author

This article was prepared by the editorial team for this publication. We focus on practical explanations and update articles when major practices change.

Last reviewed: May 2026

Share this article:

Comments (0)

No comments yet. Be the first to comment!