When designing a KMP library, how do you decide what belongs in `commonMain` versus platform source sets, and what are the trade-offs of pushing more code into common?
answer
- Domain common, adapters per-platform (hexagonal)
- More common = more reuse + shared tests, but LCD + dep lock
- Interfaces+DI over expect/actual for collaborators
- Every common dep = long-term all-target commitment
- expect/actual only for global capabilities
basics
~10 sPut as much platform-independent logic in commonMain as possible to maximize reuse and shared tests, but keep platform APIs out. Over-sharing forces awkward abstractions; under-sharing duplicates logic.
solid answer
~40 sThe guiding principle is: maximize the shared surface in `commonMain` so business logic, models, and validation are written and tested once, while isolating genuinely platform-bound concerns (file I/O, crypto, UI, system clock) behind `expect`/`actual` or interfaces. Pushing more into common pays off in shared `commonTest` coverage and a single source of truth, but it constrains you to multiplatform-safe dependencies and the lowest-common-denominator API, and can force `expect`/`actual` ceremony or leaky abstractions when platforms diverge. The counterweight: code that's inherently platform-specific or that would need a clumsy `expect` for every method is better left in `jvmMain`/`iosMain`. Practically, you keep the *domain* common and the *adapters* per-platform (hexagonal style), prefer interfaces + DI over `expect`/`actual` for swappable collaborators, and treat each common dependency as a long-term commitment because every target must support it.
go deeper
Knows logic goes in common and platform APIs go in platform source sets.
Explains the reuse/test benefit of common and that platform APIs and actuals live per-platform.
Articulates concrete trade-offs (LCD APIs, dependency constraints, expect/actual ceremony) and applies ports-and-adapters with DI.
Sets architecture and dependency governance: defines the shared/platform boundary, evaluates each common dep as a long-term all-target commitment, and balances reuse against abstraction cost and future target additions.
## The core decision KMP's value is **write once, run on every target**. So the default bias is: put logic in `commonMain` unless it *must* be platform-specific. But that bias has limits, and a principal-level answer reasons about both directions. ## What clearly belongs in `commonMain` - **Domain models** and value types (data classes, sealed hierarchies, enums). - **Business logic / use cases** — pure functions, state machines, validation. - **Serialization contracts** (kotlinx.serialization `@Serializable` classes). - **Coroutine-based orchestration** using multiplatform-safe libs. - **`expect` declarations** for the few platform capabilities the domain needs. Keeping these common means a single `commonTest` suite verifies them across all targets — the biggest testing win in KMP. ## What belongs in platform source sets - Anything touching `java.*`, `android.*`, `platform.Foundation.*`/`platform.UIKit.*`. - File system, secure storage, crypto, networking engines, threading primitives. - UI and lifecycle integration. - The `actual` side of `expect` declarations, and platform-only test assertions in `jvmTest`/`iosTest`. ## Trade-offs of pushing more into common **Benefits** - One implementation, one test suite, no drift between platforms. - Smaller per-platform code; faster onboarding. - Refactors happen in one place. **Costs** - Locked to **multiplatform-safe dependencies** — every common dep must publish for all targets and becomes a long-term commitment. - **Lowest-common-denominator APIs** — you can't reach a richer platform API without abstraction. - **`expect`/`actual` ceremony** — over-sharing borderline code forces an `expect` per method and brittle signatures. - **Leaky abstractions** when platforms genuinely diverge (e.g. iOS main-thread constraints, JS single-threadedness). ## A practical architecture Use a **hexagonal / ports-and-adapters** shape: ```kotlin // commonMain — port (interface) + pure domain interface Clock { fun nowMs(): Long } class Scheduler(private val clock: Clock) { fun isDue(target: Long) = clock.nowMs() >= target // pure, shared, testable } ``` ```kotlin // jvmMain / iosMain — adapters injected at runtime class SystemClock : Clock { override fun nowMs() = System.currentTimeMillis() } ``` - **Domain common, adapters per-platform.** - Prefer **interfaces + DI** over `expect`/`actual` for swappable collaborators (more testable in `commonTest` with fakes). - Reserve `expect`/`actual` for single global capabilities (UUID, atomics, time). ## Governance considerations - Each `commonMain` dependency must support every target; vet new ones the way you'd vet a public API. - Adding a target later forces new `actual`s and re-validates every common dep — design for that. - Measure: how much code is shared vs. duplicated, and is the shared part actually carrying test coverage? ## Key takeaways - Bias toward common for domain/logic; keep platform APIs and adapters out. - More common = more reuse + shared tests, but LCD APIs, dep constraints, and `expect`/`actual` ceremony. - Ports-and-adapters + interfaces/DI keeps common pure and testable.
- Why is each `commonMain` dependency a long-term commitment?Because it must publish artifacts for every current and future target; dropping or adding a target re-validates it, and an unmaintained MPP dependency can block a whole platform.
- Give a sign that you've pushed too much into common.Needing an `expect`/`actual` pair for almost every method of a class, or abstractions that exist only to dodge a platform API, indicate the code belongs in a platform source set.
Designing the common layer is like writing a recipe for cooks with different kitchens: shared steps and measurements go in the recipe; anything that depends on a specific oven gets a note 'do this on your equipment' (the platform adapter).
saying these in an interview costs you the question
- Saying 'put everything in common' without acknowledging dependency/LCD constraints
- Defaulting to `expect`/`actual` for every collaborator instead of interfaces + DI
- Ignoring that common deps must support all targets long-term
- No notion of ports-and-adapters / domain isolation
- Treating platform divergence as always solvable by more abstraction