Back to Blog

Unlocking Platform-Specific APIs in KMP: expect/actual vs. Interface Injection

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

  1. Declare the expectation in commonMain:
// commonMain/kotlin/com/example/portfolio/platform/AppInfo.kt
expect fun getAppVersion(): String
  1. 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" 
}
  1. 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

  1. Define the interface in commonMain:
// commonMain/kotlin/com/example/portfolio/platform/NetworkMonitor.kt
interface NetworkMonitor {
    fun isConnected(): Boolean
    fun getConnectionType(): String
}
  1. 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"
        }
    }
}
  1. 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
}
  1. 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:

  1. Accessing platform-dependent classes that are part of method signatures (like passing an Android Context).
  2. Creating platform-specific annotations.
  3. 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.

Let's Connect & Discuss Your Project