When designing a KMP library's public surface, when should you use `expect`/`actual` versus a plain interface with platform-supplied implementations? What are the tradeoffs?
answer
- expect/actual = one impl/target, compile-time, no indirection
- interface+DI = many impls, mockable, runtime-wired
- expect/actual is hard to mock in tests
- hybrid: public interface, internal expect/actual primitive
- Ktor/SQLDelight expose injectable interfaces
basics
~20 sUse expect/actual when there is exactly one natural implementation per platform that callers shouldn't choose. Use a plain interface injected per platform when you want multiple implementations, easy mocking, or looser coupling. Interfaces are more flexible; expect/actual is more rigid but compiler-enforced.
solid answer
~50 s`expect`/`actual` binds one common declaration to exactly one implementation per target at compile time — the compiler guarantees every target has an actual, and there's no runtime indirection. It fits primitives with a single canonical platform answer (UUID, clock, file path). But it is rigid: one actual per target, awkward to mock, and changes ripple across all source sets. A **plain interface + dependency injection** (e.g. `interface Clock { fun now(): Long }` with platform classes passed in) is more flexible: multiple implementations, trivial fakes for tests, swappable at runtime, and the platform wiring lives in app code rather than the library. Many mature libraries expose interfaces and only use expect/actual internally for the irreducible platform primitive, keeping the public API injectable and testable. Choose expect/actual for irreducible single-impl primitives; interfaces for everything callers might vary or fake.
code
kotlin · 14 lines// Public, testable interface
interface AnalyticsSink { fun send(event: String) }
// Irreducible platform primitive behind expect/actual
internal expect fun platformDeviceId(): String
class DefaultAnalytics(
private val sink: AnalyticsSink, // injected, fakeable in tests
) {
fun track(name: String) =
sink.send("$name@${platformDeviceId()}")
}
// commonTest can pass a fake AnalyticsSink without any platform codego deeper
Can state that interfaces are more flexible and expect/actual is one-per-platform.
Contrasts compile-time binding vs runtime injection and notes mocking difficulty of expect/actual.
Gives concrete heuristics and cites how Ktor/SQLDelight expose injectable interfaces over platform primitives.
Articulates the hybrid pattern, API-evolution and testability tradeoffs, and where to draw the platform seam in a public library surface.
## The two mechanisms - **`expect`/`actual`:** a common declaration with no body (`expect`) bound at **compile time** to one **`actual`** per target. Compiler-enforced exhaustiveness, zero runtime indirection, single implementation per platform. - **Interface + injection:** declare a normal `interface` in `commonMain`; provide platform implementations as ordinary classes and **pass them in** (constructor injection / DI container / factory). Resolved by whoever constructs the object, at runtime. ## When `expect`/`actual` shines - There is **exactly one** sensible implementation per platform and callers should not choose (e.g. `randomUUID()`, `currentTimeMillis()`, platform file separators, `Dispatchers.Main`). - You want the **compiler to force** every target to provide it — you cannot forget a platform. - You want **no runtime indirection** or allocation — it compiles to a direct call. - You can satisfy it cheaply via **`actual typealias`** to an existing native type. ## When a plain interface is better - You need **multiple implementations** or want consumers to swap behavior (prod vs staging engine, in-memory vs disk). - **Testability:** an interface is trivially faked/mocked in `commonTest`; an `expect`/`actual` is hard to substitute because it is statically bound (you'd need build-flavor tricks or a separate test source set). - **Looser coupling / inversion of control:** platform wiring lives in **app code**, not baked into the library, so the library stays agnostic and DI-friendly. - The contract may **evolve** with optional members — interfaces with default methods evolve more gracefully than expect declarations that ripple across all source sets. ## Tradeoffs table (mental model) - Flexibility: interface > expect/actual. - Compile-time exhaustiveness guarantee: expect/actual > interface (interface implementations aren't forced to exist per target). - Testability/mocking: interface > expect/actual. - Runtime cost: expect/actual (none) ≤ interface (one virtual call). - Coupling: interface keeps platform choice at the edges; expect/actual fixes it in the library. ## The pragmatic hybrid that real libraries use Expose an **interface** as the public, injectable surface and use `expect`/`actual` only for the **irreducible platform primitive** inside: ```kotlin // commonMain — public, injectable interface Clock { fun nowMillis(): Long } // commonMain — internal primitive via expect/actual internal expect fun platformNowMillis(): Long class SystemClock : Clock { override fun nowMillis() = platformNowMillis() } ``` Now consumers depend on `Clock` (mockable, swappable), while the single unavoidable platform call hides behind expect/actual. Ktor (interface-driven engine factories), SQLDelight (`SqlDriver` interface injected, drivers are platform classes), and coroutines (`Dispatchers.Main` as a primitive) all reflect variants of this split. ## Decision heuristic Ask: *"Will any caller ever want a different implementation, or need to fake it in a test?"* If yes → interface + injection. If it is a single canonical platform answer that should be invisible and forced on every target → `expect`/`actual`. Keep the public API on the flexible side and push expect/actual down to the leaf primitive.
- Why is `expect`/`actual` awkward for unit testing?It is statically bound at compile time, so you cannot substitute a fake without build/source-set tricks; an injected interface lets you pass a fake in commonTest directly.
- Can you combine both approaches?Yes — expose an injectable interface as the public API and use expect/actual only for the single unavoidable platform primitive internally, getting flexibility plus a thin native seam.
expect/actual is a sealed factory bolted to the build; an injected interface is a socket you can plug any implementation into, including a test dummy.
saying these in an interview costs you the question
- Using expect/actual for everything, making the API unmockable
- Claiming interfaces guarantee a per-target implementation the way expect/actual does
- Ignoring testability when designing the public surface
- Thinking expect/actual has runtime dispatch overhead like an interface call when it doesn't
- Baking platform choice into the library when consumers need to vary it