When building cross-platform apps with Kotlin Multiplatform (KMP), you will eventually need to execute platform-specific APIs. Whether you are generating device UUIDs, registering push notification tokens, querying Bluetooth status, or opening a local web view, your shared code needs a way to talk to Android (JVM/Android SDK) and iOS (Objective-C/CocoaTouch).
To bridge this gap, KMP developers have two primary choices: Kotlin's built-in expect/actual keywords or Interface Injection using dependency injection. While they achieve the same result, their architectural implications are vastly different.
Method 1: Kotlin's Native expect/actual
The expect/actual mechanism is a language-level feature built into Kotlin. It acts like a compiler contract: you define an expect class or function in commonMain, and the compiler forces you to write corresponding actual implementations in every platform target (androidMain, iosMain, etc.).
Example: Retrieving the App Version
- Declare the expectation in
commonMain:
// commonMain/kotlin/com/example/portfolio/platform/AppInfo.kt
expect fun getAppVersion(): String
- Provide the actual Android implementation (
androidMain):
// androidMain/kotlin/com/example/portfolio/platform/AppInfo.kt
import android.content.Context
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
actual fun getAppVersion(): String {
// This is simple but accesses JVM/Android classes directly
return "1.0.0-android"
}
- Provide the actual iOS implementation (
iosMain):
// iosMain/kotlin/com/example/portfolio/platform/AppInfo.kt
import platform.Foundation.NSBundle
actual fun getAppVersion(): String {
val dictionary = NSBundle.mainBundle.infoDictionary
val version = dictionary?.[ "CFBundleShortVersionString" ] as? String
return version ?: "1.0.0-ios"
}
When to use expect/actual
- For simple static functions, constants, or types (e.g., retrieving system timestamps or formatting strings).
- When exposing custom platform classes that cannot be cleanly abstracted behind a standard Kotlin interface.
- Inside low-level multiplatform libraries.
Method 2: Interface Injection (Recommended)
Interface Injection is a standard object-oriented design pattern. Instead of using compiler magic, you define a regular Kotlin interface in commonMain. You then write concrete implementations in androidMain and iosMain, and inject them into your business logic using a Dependency Injection (DI) container like Koin.
Example: Checking Network Connection Type
- Define the interface in
commonMain:
// commonMain/kotlin/com/example/portfolio/platform/NetworkMonitor.kt
interface NetworkMonitor {
fun isConnected(): Boolean
fun getConnectionType(): String
}
- Implement on Android (
androidMain):
// androidMain/kotlin/com/example/portfolio/platform/AndroidNetworkMonitor.kt
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
class AndroidNetworkMonitor(private val connectivityManager: ConnectivityManager) : NetworkMonitor {
override fun isConnected(): Boolean {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
override fun getConnectionType(): String {
val activeNetwork = connectivityManager.activeNetwork ?: return "NONE"
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return "NONE"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR"
else -> "OTHER"
}
}
}
- Implement on iOS (
iosMain):
// iosMain/kotlin/com/example/portfolio/platform/IosNetworkMonitor.kt
import platform.Network.nw_path_monitor_create
import platform.Network.nw_path_monitor_start
import platform.Network.nw_path_monitor_set_update_handler
class IosNetworkMonitor : NetworkMonitor {
private var connected = false
private var connectionType = "WIFI"
init {
val monitor = nw_path_monitor_create()
nw_path_monitor_set_update_handler(monitor) { path ->
// Objective-C Network check logic here
this.connected = true // example value
}
nw_path_monitor_start(monitor)
}
override fun isConnected() = connected
override fun getConnectionType() = connectionType
}
- Inject using Koin:
Instead of compiler mapping, register these classes directly inside your platform Koin modules, letting your common repositories consume the interface cleanly:
// commonMain/kotlin/com/example/portfolio/feed/FeedRepository.kt
class FeedRepository(
private val networkMonitor: NetworkMonitor // Common interface injected transparently!
)
Comparison Matrix
| Feature | expect / actual | Interface Injection |
|---|---|---|
| Mechanic | Compiler contract | OOP Polymorphism & DI |
| Unit Testing | Hard to mock (requires mock compilers or source sets) | Very easy to mock (just pass MockNetworkMonitor() in tests) |
| Coupling | High (bound to compiler targets) | Low (decoupled behind a clean interface boundary) |
| Dependency Injection | Does not naturally support DI arguments | Integrates perfectly with DI containers like Koin |
| Best For | Low-level variables, types, and standalone utilities | Business logic, state monitors, databases, and heavy APIs |
Key Recommendation: The "Interface-First" Rule
In 90% of cases, you should choose Interface Injection over expect/actual.
Using expect/actual constructs global classes and static functions that cannot be easily mocked during unit tests. If your repository relies on an expect fun getAppVersion(), writing a unit test for that repository in commonTest requires you to provide a test runtime implementation, which introduces significant configuration boilerplate.
By contrast, using an interface allows you to write mock implementations immediately in commonTest without touching native source sets.
Reserve expect/actual for:
- Accessing platform-dependent classes that are part of method signatures (like passing an Android
Context). - Creating platform-specific annotations.
- Defining low-level factory wrappers.
Conclusion
Understanding when to leverage compiler-based expect/actual versus clean interface structures is essential for scaling Kotlin Multiplatform codebases. By adopting an interface-first design, you ensure your shared core remains testable, decoupled, and easily maintainable.
To learn how to manage the lifecycle of these interfaces across platforms, read my guide on State Management in Kotlin Multiplatform, or see how to register them dynamically in my tutorial on Mastering Dependency Injection with Koin.
Need a Kotlin Multiplatform Expert?
Looking to establish clean architecture abstractions, build native platform integrations, or improve testability inside your shared mobile codebase? I provide remote contract development and consulting services for engineering teams in Europe, the UK, and Canada.
- Explore my Kotlin Multiplatform Development Services for implementation and UI design support.
- Learn about my Kotlin Multiplatform Consulting Services for strategic architecture audits and blueprints.