Use Interfaces First for Platform APIs in Kotlin Multiplatform
expect and actual are often the first Kotlin Multiplatform feature developers learn. They are also easy to overuse.
The mechanism is straightforward: common code declares what it expects, and platform source sets provide the matching implementation. That is useful when shared code needs a platform-specific capability. But it should not become the default shape of every dependency in a shared module.
Start with the capability the product needs
Imagine a shared feature that needs secure storage. The feature does not need to know whether iOS uses Keychain or Android uses an encrypted storage implementation. It needs a capability: save a value, read it, and remove it.
That capability can be expressed as a regular Kotlin interface. The iOS and Android entry points can provide their own implementations, and the shared feature receives the dependency through a constructor or a dependency-injection container. Now the feature can be tested with a fake implementation, without invoking platform APIs.
This is not an argument against expect/actual. It is an argument for separating the question “what does the shared feature need?” from “how does each platform provide it?”
The Kotlin documentation describes both approaches. When the platform implementation can be constructed at the app entry point, an interface passed into common code avoids expect/actual entirely. For other cases, expected and actual declarations provide a direct bridge. The official guide to platform-specific APIs covers these options and recommends using an existing dependency-injection approach consistently.
Keep expect/actual close to the platform seam
There are cases where expect/actual is the cleanest answer: a small platform query, a type that must map to platform APIs, or a capability whose implementation is naturally selected by the target. The problem begins when every platform dependency becomes an expected declaration and the common module accumulates implicit assumptions about each target.
I use a simple test: if I can name the capability in product language, it probably deserves an interface. SecureStore, Clock, FilePicker, or AnalyticsSink tells us what the feature needs. PlatformThing tells us almost nothing.
The interface also gives the team a place to define behavior. For example, should a secure-store read return null when a key is missing? What happens if the device cannot unlock the key? Those decisions are part of the contract, not an incidental detail of Keychain or Android Keystore.
Make platform differences visible in composition
The application’s composition root is a good place to wire platform implementations. On Android, construct the Android implementation. In the iOS app, construct the iOS implementation and pass it into the shared Kotlin entry point. If the project already uses Koin or another DI framework, use that consistently rather than mixing several wiring styles.
This keeps the shared feature honest: it can depend only on capabilities in its constructor, not on a global platform object or a hidden singleton. It also helps tests state exactly which behavior they are exercising.
A practical rule
Use an interface and injection when you want a replaceable capability, an obvious test seam, or implementations supplied by the app. Use expect/actual when the platform-specific declaration itself is the simplest bridge and does not distort the shared architecture.
Neither technique is a badge of purity. The goal is a shared module with explicit dependencies and platform code that remains easy to locate. expect/actual is a language mechanism; the architecture is the set of boundaries the team can explain and maintain.