skip to content

How do you decide what belongs in commonMain versus platform code, and what architectural patterns keep the boundary clean?

level: seniorimportance: should knowfreq 40%

answer

  1. Platform-agnostic → common; platform API → behind abstraction
  2. Common defines interface, platform implements + DI
  3. Share logic/networking/serialization/coroutines
  4. expect/actual only for thin leaf bindings
  5. Don't leak JVM-only libs into commonMain

basics

~10 s

Put 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 s

Share 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
kotlin
// 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

for a junior

Knows logic goes in common and device-specific code goes per platform.

for a middle

Names concrete shared libraries (Ktor, serialization, coroutines) and uses interfaces for platform seams.

for a senior

Designs the interface+DI boundary, reserves expect/actual for leaves, and avoids leaking JVM-only deps.

for a principal

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

context