# 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.

- URL: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di
- Course: [Mobile Action Pro](https://dantech.academy/en/courses/kotlin-accelerator.md)
- Module: Kotlin Multiplatform Foundations
- Duration: 15 min
- Difficulty: beginner
- Access: Free, full lesson
- Tiếng Việt: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di.md

## Objectives

- 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

---

## 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](https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html), [Use platform-specific APIs](https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html).

## 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.

- `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:

| 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:

- `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

| 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 fun`s 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

| 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 `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.

---

- Previous lesson: [Anatomy Of A KMP Project](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md)
- Next lesson: [iOS Host And Swift Interop](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop.md)
