How do you decide what belongs in commonMain versus platform code, and what architectural patterns keep the boundary clean?
answer
- Platform-agnostic → common; platform API → behind abstraction
- Common defines interface, platform implements + DI
- Share logic/networking/serialization/coroutines
- expect/actual only for thin leaf bindings
- Don't leak JVM-only libs into commonMain
basics
~10 sPut logic that doesn't depend on a specific platform (rules, data, networking) in commonMain. Put things that touch a device API in platform code. Hide platform details behind interfaces so common code stays clean.
solid answer
~40 sShare what's **platform-agnostic** in `commonMain`: domain models, validation, networking (Ktor client), serialization (kotlinx.serialization), state/view-model logic. Keep **platform-specific** concerns (UI, secure storage, push tokens, file system, time, threading specifics) in platform source sets. The clean-boundary pattern is **'common defines the interface, platform provides the implementation'**: declare an `interface` in `commonMain`, implement it per platform, and inject it (constructor/DI like Koin). Reserve `expect/actual` for thin leaf bindings, not large abstractions, because expect classes couple source sets and resist refactoring. Concurrency primitives are common via `kotlinx.coroutines`. The litmus test: if code would need an `if (platform)` branch or a platform API, it belongs behind an abstraction, not in commonMain.
code
kotlin · 15 lines// commonMain — abstraction + shared logic
interface Clock { fun nowMillis(): Long }
class TokenService(private val clock: Clock) {
fun isExpired(expiryMillis: Long) = clock.nowMillis() >= expiryMillis
}
// jvmMain
class JvmClock : Clock { override fun nowMillis() = System.currentTimeMillis() }
// iosMain
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
class IosClock : Clock {
override fun nowMillis() = (NSDate().timeIntervalSince1970 * 1000).toLong()
}go deeper
Knows logic goes in common and device-specific code goes per platform.
Names concrete shared libraries (Ktor, serialization, coroutines) and uses interfaces for platform seams.
Designs the interface+DI boundary, reserves expect/actual for leaves, and avoids leaking JVM-only deps.
Sets architectural conventions, defines the shared-module boundary across a codebase, and plans for testability, refactor cost, and adding future targets.
## The decision rule Ask: *does this code depend on a specific platform's API or behavior?* - **No →** it belongs in **`commonMain`**. - **Yes →** put it in platform code, behind an abstraction. ## What naturally lives in commonMain - **Domain models / DTOs** and **business rules / validation**. - **Networking** via the **Ktor** multiplatform client. - **Serialization** via **`kotlinx.serialization`**. - **Concurrency** via **`kotlinx.coroutines`** (`suspend`, `Flow`, `StateFlow`). - **Presentation logic** — shared view models / state holders. These only use multiplatform libraries, so they compile for every target. ## What stays platform-specific - **UI** (unless using Compose Multiplatform). - **Secure storage / Keychain / KeyStore**, **push tokens**, **file paths**, **permissions**. - Anything touching **Foundation/Darwin** or **Android SDK** directly. ## Pattern 1 — interface in common, implementation per platform (preferred) ```kotlin // commonMain interface KeyValueStore { fun put(key: String, value: String) fun get(key: String): String? } // commonMain consumer takes it via DI class SessionRepo(private val store: KeyValueStore) { /* ... */ } // androidMain class AndroidStore(ctx: Context) : KeyValueStore { /* SharedPreferences */ } // iosMain class IosStore : KeyValueStore { /* NSUserDefaults */ } ``` Inject the right implementation with a DI framework (e.g. **Koin**) or constructor wiring at the app edge. This is testable (you can pass a fake) and refactor-friendly. ## Pattern 2 — expect/actual for thin leaf bindings Good for one-liners like `currentTimeMillis()` or `randomUuid()`. Avoid `expect class` for large abstractions: it spreads implementation across source sets and is painful to evolve. ## Why keep the boundary thin - **Testability**: most logic in `commonMain` runs in `commonTest` on the JVM quickly. - **Portability**: adding a new target means implementing a few interfaces, not rewriting logic. - **Refactorability**: interfaces decouple; expect classes couple. ## Common mistakes - Leaking a JVM-only library (e.g. `java.time`, Jackson) into shared code, breaking native/JS compilation. - Using `expect class` where an interface would do. - Pushing UI-state logic into platform code, duplicating it across iOS/Android. ## Litmus test If the code would need `if (Platform.isIos)` or a platform import, it doesn't belong in `commonMain` — hide it behind an interface and inject the platform implementation.
- Why prefer an injected interface over expect class for storage?Interfaces are mockable in commonTest, avoid coupling source sets, and let you swap or add platforms by writing one new implementation.
- What breaks if you import java.time into commonMain?Non-JVM targets (native, JS, Wasm) won't compile because that API isn't available there; use kotlinx-datetime instead.
commonMain is the engine; platform code is the adapter plugs that connect it to each country's wall socket.
saying these in an interview costs you the question
- Importing JVM-only libraries (java.time, Jackson) into commonMain
- Using expect class for every abstraction
- Branching on platform inside common code
- Duplicating view-model logic per platform
- No DI/interface seam, so common code depends directly on platform types