The first difficult Kotlin Multiplatform decision is often not how much code to share. It is where platform behavior should enter the shared code.
Take an article reader that opens an external link. Android needs an appropriate Context and intent. iOS needs its own application APIs. Shared presentation logic only needs to request an action and handle its outcome.
That is a useful place for a small contract.
Start with the caller's needs
Put the following in commonMain:
enum class OpenLinkResult { Opened, Unavailable }
interface ExternalLinkOpener {
suspend fun open(url: String): OpenLinkResult
}
class ArticleActions(
private val linkOpener: ExternalLinkOpener,
) {
suspend fun openSource(url: String): OpenLinkResult {
if (!url.startsWith("https://")) {
return OpenLinkResult.Unavailable
}
return linkOpener.open(url)
}
}
This deliberately small example rejects non-HTTPS input. A real application still needs URI parsing and any product-specific host rules; checking a prefix is not a security validation strategy.
The dependency describes a capability. It does not expose an Android Context, an Intent, or an iOS application object to every caller.
Also define what Opened means: the platform accepted the handoff, not that the page loaded or the user read it. Clear semantics make the contract useful across platforms.
Inject an implementation at the edge
Each platform creates its implementation and passes it to ArticleActions. Constructor injection is enough; a dependency injection framework is optional.
Android entry point -> AndroidLinkOpener -> ArticleActions
iOS entry point -> IOSLinkOpener -> ArticleActions
commonTest -> FakeLinkOpener -> ArticleActions
Keep platform thread requirements, lifecycle handling, and native error translation in the adapters. A suspend declaration does not automatically switch threads or make a platform call cancellable.
If the adapter performs suspending work, preserve coroutine cancellation instead of turning it into an ordinary Unavailable result. Convert expected platform failures deliberately; do not blanket-catch every programming error.
Where expect/actual fits
When the shared module should provide its own construction entry point, an expected factory can return the interface:
// commonMain
expect fun createExternalLinkOpener(): ExternalLinkOpener
The corresponding actual functions belong to the relevant platform source sets and package. Their implementations are omitted here because construction may require platform dependencies. A no-argument factory is only suitable when those dependencies can be obtained appropriately; otherwise pass them at a platform entry point.
The interface and expect/actual serve different purposes. The interface is the behavior contract. The factory selects a construction path. You do not need an expected class for every platform service.
Test behavior without opening a browser
This common test verifies that an unsupported scheme never reaches the platform adapter:
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ArticleActionsTest {
@Test
fun unsupportedSchemeDoesNotReachAdapter() = runTest {
var calls = 0
val fake = object : ExternalLinkOpener {
override suspend fun open(url: String): OpenLinkResult {
calls++
return OpenLinkResult.Opened
}
}
val result = ArticleActions(fake).openSource("file:///notes")
assertEquals(OpenLinkResult.Unavailable, result)
assertEquals(0, calls)
}
}
Use kotlin.test and kotlinx-coroutines-test in commonTest, aligned with your project versions. The snippets describe a module design, not a complete app setup.
This test checks the shared decision. Platform tests still need to verify the actual handoff, valid URLs, unavailable handlers, and lifecycle behavior.
A useful boundary review
Before introducing a platform abstraction, ask:
- Does the caller need a capability, or the native object itself?
- Can the outcome mean the same thing on every supported platform?
- Who supplies platform dependencies and owns their lifetime?
- Can a fake verify shared behavior without pretending to test native integration?
Share policy and intent where they are genuinely common. Keep native mechanics at a boundary you can explain and test.
Top comments (0)