The KMP Default Trap
Kotlin Multiplatform Mobile (KMP) tutorials often introduce expect/actual as a core feature, sometimes even in the first few minutes. This leads new teams to adopt it as their go-to solution for platform-specific code. While expect/actual is a powerful tool for a precise set of problems, its overuse as a default creates tangled shared modules and hinders maintainability. It’s a specialized hammer, not an all-purpose screwdriver.
The core of KMP’s appeal is writing shared business logic once and deploying it across multiple platforms like iOS and Android. When platform-specific implementations are needed—think file system access, network calls, or UI elements—developers reach for expect/actual. This mechanism allows you to declare an API in a common module (the expect declaration) and then provide a concrete implementation for each target platform (the actual declarations).
However, the ease with which expect/actual can be implemented encourages its widespread adoption. Developers, eager to leverage KMP’s cross-platform capabilities, often default to this pattern even when simpler alternatives exist. This habit, ingrained early in the learning process, can lead to a shared module structure that is difficult to navigate, test, and refactor. Each expect/actual pair represents a branching point, a place where the shared code diverges. When these divergences become too numerous or too complex, the “shared” code starts to feel less shared and more like a collection of platform-specific stubs loosely held together.

When expect/actual Makes Sense
expect/actual excels when you need to abstract away a platform-specific API that has a direct, one-to-one mapping on each target. For instance, accessing the device’s unique identifier, interacting with specific hardware features, or implementing platform-specific logging mechanisms are prime candidates. In these scenarios, expect/actual provides a clean, type-safe way to define a common interface and supply the necessary native implementations.
Consider a scenario where you need to get the current battery level of a device. The Android API for this is different from the iOS API. Using expect/actual, you could define:
// commonMain
expect fun getBatteryLevel(): Int
// androidMain
actual fun getBatteryLevel(): Int = /* Android specific implementation */
// iosMain
actual fun getBatteryLevel(): Int = /* iOS specific implementation */
This works efficiently because the underlying functionality is conceptually the same across platforms, even if the implementation details differ. The expect declaration acts as a contract, ensuring that each platform provides the required functionality. The compiler enforces this contract at build time, preventing runtime errors due to missing implementations.
The Pitfalls of Defaulting
The problem arises when expect/actual is used for more complex or abstract requirements. When shared code needs to perform an action that doesn’t have a clear, singular platform equivalent, or when the implementation logic varies significantly, forcing it into an expect/actual pattern can be counterproductive. This can lead to:
- Overly Complex Implementations:
actualimplementations might become bloated with conditional logic (e.g., `if (isAndroid) { ... } else { ... }` within anactualblock, which defeats the purpose) or require extensive helper functions, obscuring the platform-specific code. - Testing Challenges: Mocking and testing code that relies heavily on
expect/actualcan become more difficult, especially if the underlying platform APIs are not easily mockable. - Module Interdependencies: Overuse can lead to tightly coupled shared modules, where changes in one module necessitate changes in others due to complex
expect/actualchains. - Build Times: While not always a primary concern, complex
expect/actualsetups can sometimes contribute to longer compilation times.
The mental model of “if it’s platform-specific, use expect/actual” becomes a crutch. It’s akin to using a highly specialized surgical tool for every task, when a simple household hammer would suffice for many applications. This approach overlooks simpler, more maintainable patterns that might be available.
Better Defaults for Shared Code
For many common cross-platform needs, developers should consider alternatives before defaulting to expect/actual. These alternatives often lead to cleaner, more robust shared code:
1. Pure Kotlin Shared Logic
The first and best approach is to write as much logic as possible in pure Kotlin. If an operation can be implemented using standard Kotlin libraries or logic that doesn’t depend on platform specifics (e.g., data transformation, business rules, algorithms), do so. This maximizes code sharing and minimizes the need for platform-specific code.
2. Dependency Injection (DI)
For platform-specific behaviors that don’t map cleanly to a single expect/actual declaration, dependency injection is often a superior pattern. Instead of expecting a specific function, you can inject an interface or a factory that provides the platform-specific service. The common module defines the interface, and each platform provides a concrete implementation. This decouples the common code from the platform specifics more effectively than expect/actual.
For example, instead of expect fun getPlatformSpecificService(): Service, you might define:
// commonMain
interface PlatformSpecificService {
fun performAction(): Result
}
// commonMain
// Business logic that USES PlatformSpecificService
class MyBusinessLogic(private val service: PlatformSpecificService) {
fun doSomething() = service.performAction()
}
// androidMain
actual class PlatformSpecificService : Service { /* Android impl */ }
// iosMain
actual class PlatformSpecificService : Service { /* iOS impl */ }
This pattern makes the dependency explicit and testable. You can easily provide mock implementations of PlatformSpecificService in your common tests.
3. Multiplatform Libraries
Leverage existing Kotlin Multiplatform libraries for common tasks like networking (Ktor), serialization (kotlinx.serialization), coroutines, and SQLDelight for databases. These libraries are designed to work across multiple platforms and abstract away the underlying native APIs. Using these libraries often means you need far less custom platform-specific code, reducing the reliance on expect/actual.
The Unanswered Question: Refactoring Existing Projects
While the advice for new projects is clear—avoid defaulting to expect/actual—what about the thousands of existing KMP projects that have already embraced this pattern? Refactoring deeply entrenched expect/actual implementations can be a significant undertaking. Identifying which expect/actual pairs are truly necessary and which could be refactored to pure Kotlin or DI patterns requires careful analysis. Without clear guidance or tooling to assist in this refactoring process, many teams may be stuck with complex, hard-to-maintain shared modules, perpetuating the issue.
Conclusion: A Tool, Not a Default
expect/actual is a vital part of Kotlin Multiplatform, enabling developers to bridge the gap between shared logic and platform-specific features. However, its power comes with a responsibility. Treating it as the default mechanism for handling platform differences leads to code that is harder to manage, test, and evolve. By prioritizing pure Kotlin logic, employing dependency injection for abstract platform services, and leveraging mature multiplatform libraries, developers can build more robust and maintainable KMP applications. Reserve expect/actual for the specific cases where it truly shines: direct, one-to-one platform API abstractions.
