- Read every platform seam in the repo and explain why each one uses expect/actual or interface + DI
- Apply the rule for choosing a tool when you add a new seam to a KMP project
- Understand how PlaceholderBinding, PlaceholderLedgerTest and AppGraphTest keep the two platforms in sync
Platform Seams: expect/actual Or Interface + DI
Choose between expect/actual and interface + DI wherever shared code has to call the platform, based on the repo's three expect declarations and its real interface seams.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
Every KMP app has places where shared code has to call the platform: playing a sound, vibrating, opening the share sheet, signing in with Google, choosing a folder to store files in. This lesson calls those places platform seams and teaches you to choose between the two tools Kotlin gives you: expect/actual, or an interface in commonMain with the implementation supplied through dependency injection (DI).
After this lesson, you can read every seam in the repo, know why each seam uses the tool it does, and make the right choice yourself when you add a new seam to any KMP project.
The most familiar example is AudioManager: one interface, two implementations (SoundPoolAudioManager for Android, AvAudioManager for iOS). This lesson gets into the design question behind it.
Part 1: Two Tools
expect/actual is a language mechanism. You declare an expect in commonMain, then each platform writes an actual with the same name in the same package. The compiler checks that every expect has an actual on every target. An actual can live in an intermediate source set such as iosMain; you don't need separate ones for iosArm64Main and iosSimulatorArm64Main.
Interface + DI uses only ordinary Kotlin. The interface lives in commonMain, the implementations live in androidMain/iosMain, and Koin picks the implementation when the app starts.
The Kotlin docs lean clearly toward the second. The expect/actual page advises: if the project already uses a DI framework, use it for platform dependencies too, and only reach for expect/actual in the DI configuration. The same page notes that expect class is still in Beta and shouldn't be used where an interface is enough, because an interface allows several implementations on one platform and is easy to swap for a fake in tests.
Docs: Expected and actual declarations, Use platform-specific APIs.
Part 2: The Repo's Three expect Declarations
Search production code for the keyword expect and you find only three places:
| Declaration | File | Android | iOS |
|---|---|---|---|
expect val dataPlatformModule: Module | :data, DataPlatformModule.kt | A Koin module with SoundPool, Vibrator, Firebase | A Koin module with AVFoundation, UIKit, placeholders |
internal expect val platformIoDispatcher | :kernel, QzDispatchersImpl.kt | Dispatchers.IO | Dispatchers.Default |
expect fun shouldUseHaze(): Boolean | :ui-components, HazeUtils.kt | true on Android 13 and newer | always true |
All three share one trait: each is a value or a pure function that holds no state and never needs more than one implementation per platform.
dataPlatformModuleis exactly the "DI configuration" the docs mention. Shared code needs to name it to load it into Koin, but its contents differ per platform. The KDoc explains why it's anexpect valand not anexpect class: each platform only needs to supply one value, not copy the whole shape of a type.platformIoDispatcherisinternal. Outside:kernel, code only sees theQzDispatchersinterface, so the expect here is an implementation detail.shouldUseHazedecides whether the blur effect is used. The Android version callssupportsHazeBlur(sdkInt), a separateinternalfunction so it can be tested with different SDK values. The iOS version returnstruebecause Skia can blur on every iOS version.
Tests use expect too. AppGraphTest in shared-app/src/commonTest declares expect val graphPlatformModules and expect val bindingsNeedingPlatformRuntime. The actual versions live in androidHostTest and iosTest. One test, written once, runs on both platforms with each platform's own Koin modules.
Part 3: The Interface Seams
The rest of the repo uses interfaces. Here is the full list:
| Interface | Declared in | Android | iOS |
|---|---|---|---|
AudioManager | :domain | SoundPoolAudioManager | AvAudioManager |
HapticManager | :domain | VibratorHapticManager | UiKitHapticManager |
AnalyticsManager | :domain | FirebaseAnalyticsManager | NoOpAnalyticsManager |
GoogleSignInLauncher | :domain | AndroidGoogleSignInLauncher | IosGoogleSignInLauncher |
RemoteConfigManager | :data | FirebaseRemoteConfigManager | DefaultRemoteConfigManager |
DataStoreFileResolver | :data | AndroidDataStoreFileResolver | IosDataStoreFileResolver |
Notifier, ShareSheet | :core, package platform | AndroidNotifier, AndroidShareSheet | IosNotifier, IosShareSheet |
ScreenWakeLock | :core, package platform | AndroidScreenWakeLock | IosScreenWakeLock |
NotificationScheduler | :core, package platform | DailyNotificationScheduler (in :app) | IosNotificationScheduler |
The bindings live in four Koin modules: dataPlatformModule (two versions, in :data), and androidPlatformModule and iosPlatformModule (in :shared-app). NotificationScheduler on Android is the exception: it lives in :app because it uses WorkManager, and WorkManager is kept in :app.
Why do these seams use interfaces? Look at the implementations' constructors:
AndroidShareSheetneeds aContextand the current activity.AndroidGoogleSignInLauncherneeds a function that returns the resumed activity. These are objects with a lifecycle that Koin can manage, while anactual funhas nowhere to receive them.IosNotificationPermissionCheckeris bound once but serves two roles. DI allows that; expect/actual doesn't.- ViewModels and use cases receive interfaces through their constructors, so tests in
commonTestpass in fakes without touching the platform.
Part 4: The Rule For Choosing
| Situation | Choose |
|---|---|
The implementation needs a Context, an activity or another object with a lifecycle | Interface + DI |
| You want to swap in a fake for tests | Interface + DI |
| There may be several implementations on one platform (real, no-op, debug) | Interface + DI |
| A value or a pure function with no dependencies and no state | expect val / expect fun |
| The DI configuration itself has to differ per platform | An expect val holding a Koin module |
You must extend an existing platform class, or use a platform type directly through actual typealias | expect class (Beta) |
When in doubt, pick the interface. Going from an interface to an expect is rarely needed, while the reverse usually drags test code along with it.
One trap with expect fun: tests in commonTest can't replace its value. The repo avoids this by keeping shouldUseHaze very thin and pushing the logic into supportsHazeBlur(sdkInt), a function that takes a parameter and can therefore be tested. If one of your expect funs starts growing if branches, that's a sign it should become an interface.
Part 5: Seams That Do Nothing
Not every seam has a real implementation on every platform. On iOS the repo has no Firebase, so AnalyticsManager and RemoteConfigManager are bound to placeholders.
The danger of a placeholder is that it looks exactly like a broken feature: the app runs, the button responds, nothing happens. The repo handles this with two things:
- The
PlaceholderBindinginterface in:core, packageplatform, with aresolutionproperty recording the placeholder's fate: which phase will replace it, or a decision to keep it. PlaceholderLedgerTestinshared-app/src/iosTest. The test builds the whole iOS Koin graph, pulls out every binding that is aPlaceholderBinding, and compares them with an expected list.
The test's KDoc explains why it reads from the graph instead of a hand-written list: a hand-written list once missed four placeholders in :data. Two entries remain today, NoOpAnalyticsManager and DefaultRemoteConfigManager, both recorded as kept permanently because Firebase on iOS was cancelled by the owner's decision.
NoOpAnalyticsManager isn't completely silent either: it logs through Kermit every time it's called, so you can tell "analytics is a placeholder" apart from "the line that calls analytics never ran".
PlaceholderLedgerTest lives in iosTest, so it only runs through iosSimulatorArm64Test on a Mac. This step was not run on a Mac when the lesson was written.
Part 6: Keeping Both Platforms In Sync
Interface + DI has a weakness expect/actual doesn't have: forgetting a binding on one platform isn't a compile error. The app builds green and then crashes when Koin can't find the binding.
The repo makes up for this with AppGraphTest, a test that starts the whole Koin graph and resolves every binding. The test lives in commonTest and uses the expect val graphPlatformModules from Part 2: on the JVM its value is androidPlatformModule; on the iOS Simulator it is iosPlatformModule. The same test checks each platform with that platform's own Koin modules. Two of its tests relate directly to seams:
platformModulesBindTheSharedSeams: the platform module must bind all ofNotifier,ShareSheet,ScreenWakeLock,GoogleSignInLauncher,NotificationPermissionRequesterandNotificationPermissionChecker.everyRegisteredTypeResolvesWithAllItsDependencies: every binding resolves, except a short list that needs the real platform runtime. That list is checked in both directions, so nobody can pad it just to make the test green.
Run the JVM side (verified, 4 tests green):
./gradlew :shared-app:testAndroidHostTest --tests "*AppGraphTest"
This command only checks androidPlatformModule. A missing binding in iosPlatformModule is only caught by running :shared-app:iosSimulatorArm64Test on a Mac. The Mac step was not run when the lesson was written.
When you add a new interface seam that both platforms need, add it to the sharedPlatformSeams list in AppGraphTest at the same time as the two bindings.
Troubleshooting Table
| Symptom | Usual cause | Fix |
|---|---|---|
The compiler reports a missing actual for a target | You added an expect but only wrote the Android actual | Write the actual in iosMain, in the same package as the expect |
The actual has the right name but the compiler still says it doesn't match | A different package or a different signature | Compare the package and return type with the expect |
A Beta warning when using expect class | The feature isn't stable yet | Consider switching to an interface before silencing the warning |
| The iOS app crashes because Koin can't find a definition, while Android runs fine | A binding is missing in iosPlatformModule or the iOS dataPlatformModule | Add the binding, add the seam to sharedPlatformSeams, run AppGraphTest on the iOS Simulator |
| An iOS feature "does nothing" but shows no error | It's using a placeholder | Check the list in PlaceholderLedgerTest |
Checkpoint
You are done with this lesson when you can:
- list the three
expectdeclarations in production code and explain why none of them needs to be an interface, - choose an interface or an expect for three examples: reading the OS version, opening the app's settings page, writing a rotating log file,
- explain why
supportsHazeBluris split out ofshouldUseHaze, - say which kind of bug
PlaceholderLedgerTestprevents, - run
AppGraphTeston the JVM, point out which test turns red if you remove theShareSheetbinding fromandroidPlatformModule, and explain why removing it fromiosPlatformModuleleaves the JVM command green.