Dan Tech Academy
After this lesson you will
  • 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
Duration15 min
DifficultyBeginner

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.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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:

DeclarationFileAndroidiOS
expect val dataPlatformModule: Module:data, DataPlatformModule.ktA Koin module with SoundPool, Vibrator, FirebaseA Koin module with AVFoundation, UIKit, placeholders
internal expect val platformIoDispatcher:kernel, QzDispatchersImpl.ktDispatchers.IODispatchers.Default
expect fun shouldUseHaze(): Boolean:ui-components, HazeUtils.kttrue on Android 13 and neweralways 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.

  • dataPlatformModule is 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 an expect val and not an expect class: each platform only needs to supply one value, not copy the whole shape of a type.
  • platformIoDispatcher is internal. Outside :kernel, code only sees the QzDispatchers interface, so the expect here is an implementation detail.
  • shouldUseHaze decides whether the blur effect is used. The Android version calls supportsHazeBlur(sdkInt), a separate internal function so it can be tested with different SDK values. The iOS version returns true because 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:

InterfaceDeclared inAndroidiOS
AudioManager:domainSoundPoolAudioManagerAvAudioManager
HapticManager:domainVibratorHapticManagerUiKitHapticManager
AnalyticsManager:domainFirebaseAnalyticsManagerNoOpAnalyticsManager
GoogleSignInLauncher:domainAndroidGoogleSignInLauncherIosGoogleSignInLauncher
RemoteConfigManager:dataFirebaseRemoteConfigManagerDefaultRemoteConfigManager
DataStoreFileResolver:dataAndroidDataStoreFileResolverIosDataStoreFileResolver
Notifier, ShareSheet:core, package platformAndroidNotifier, AndroidShareSheetIosNotifier, IosShareSheet
ScreenWakeLock:core, package platformAndroidScreenWakeLockIosScreenWakeLock
NotificationScheduler:core, package platformDailyNotificationScheduler (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:

  • AndroidShareSheet needs a Context and the current activity. AndroidGoogleSignInLauncher needs a function that returns the resumed activity. These are objects with a lifecycle that Koin can manage, while an actual fun has nowhere to receive them.
  • IosNotificationPermissionChecker is 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 commonTest pass in fakes without touching the platform.

Part 4: The Rule For Choosing

SituationChoose
The implementation needs a Context, an activity or another object with a lifecycleInterface + DI
You want to swap in a fake for testsInterface + 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 stateexpect val / expect fun
The DI configuration itself has to differ per platformAn expect val holding a Koin module
You must extend an existing platform class, or use a platform type directly through actual typealiasexpect 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 PlaceholderBinding interface in :core, package platform, with a resolution property recording the placeholder's fate: which phase will replace it, or a decision to keep it.
  • PlaceholderLedgerTest in shared-app/src/iosTest. The test builds the whole iOS Koin graph, pulls out every binding that is a PlaceholderBinding, 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 of Notifier, ShareSheet, ScreenWakeLock, GoogleSignInLauncher, NotificationPermissionRequester and NotificationPermissionChecker.
  • 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):

bash
./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

SymptomUsual causeFix
The compiler reports a missing actual for a targetYou added an expect but only wrote the Android actualWrite 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 matchA different package or a different signatureCompare the package and return type with the expect
A Beta warning when using expect classThe feature isn't stable yetConsider switching to an interface before silencing the warning
The iOS app crashes because Koin can't find a definition, while Android runs fineA binding is missing in iosPlatformModule or the iOS dataPlatformModuleAdd the binding, add the seam to sharedPlatformSeams, run AppGraphTest on the iOS Simulator
An iOS feature "does nothing" but shows no errorIt's using a placeholderCheck the list in PlaceholderLedgerTest

Checkpoint

You are done with this lesson when you can:

  • list the three expect declarations 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 supportsHazeBlur is split out of shouldUseHaze,
  • say which kind of bug PlaceholderLedgerTest prevents,
  • run AppGraphTest on the JVM, point out which test turns red if you remove the ShareSheet binding from androidPlatformModule, and explain why removing it from iosPlatformModule leaves the JVM command green.
Sign in