Shared Module Architecture in KMP: expect/actual, Interfaces and Dependency Injection with Koin
The shared module architecture in a Kotlin Multiplatform project and the difference between
a maintainable application that scales over time and a mess of code that is difficult to test
and edit. The mechanism expect/actual and powerful but requires discipline to be
used correctly: every expect poorly positioned creates dependencies that are difficult to manage.
This guide shows how to structure the shared form by applying SOLID principles in context multiplatform: how to use interfaces to isolate platform-specific dependencies, how to configure Koin for dependency injection that works on Android and iOS, and how to get testable code in isolation — the key to a robust test suite.
What You Will Learn
- Layer architecture for the shared module: domain, data, presentation
- expect/actual: correct patterns and anti-patterns to avoid
- Interfaces to isolate platform-specific dependencies
- Koin for multiplatform dependency injection
- Testing the shared module in isolation
Layer Architecture of the Shared Module
Before talking about expect/actual, it is important to define the structure of the shared module. A clean architecture separates responsibilities into three well-defined layers, each with its dependency rules:
// Struttura raccomandata per il modulo condiviso
shared/src/commonMain/kotlin/
└── com.example.app/
├── domain/ # Layer 1: Regole business (zero dipendenze esterne)
│ ├── model/
│ │ ├── User.kt
│ │ └── Product.kt
│ ├── repository/
│ │ └── UserRepository.kt # Interfacce (non implementazioni!)
│ └── usecase/
│ ├── GetUsersUseCase.kt
│ └── SaveUserUseCase.kt
│
├── data/ # Layer 2: Accesso ai dati
│ ├── network/
│ │ ├── UserApiClient.kt # Ktor HTTP client
│ │ └── dto/ # Data Transfer Objects
│ ├── local/
│ │ └── UserLocalDataSource.kt # SQLDelight
│ └── repository/
│ └── UserRepositoryImpl.kt
│
├── presentation/ # Layer 3: ViewModels e state
│ └── UserListViewModel.kt
│
└── di/ # Dependency Injection modules
├── CommonModule.kt
└── PlatformModule.kt # expect (implementazione platform-specific)
La Dependency Rule fundamental: each layer can only depend on layers interior. The domain layer knows nothing about Ktor, SQLDelight, or Android/iOS. The data layer implements the domain layer interfaces. The presentation layer uses the domain's use cases.
The Correct Pattern of expect/actual
expect/actual must be used for implementations platform-specific,
not for business APIs. The right place for expect and in the infrastructure parts
(database drivers, HTTP engine, filesystem access), not in templates or use cases.
// CORRETTO: expect per infrastruttura platform-specific
// commonMain/kotlin/com/example/app/data/local/DatabaseDriverFactory.kt
expect class DatabaseDriverFactory(context: Any? = null) {
fun create(): SqlDriver
}
// androidMain: usa Android SQLite
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import android.content.Context
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return AndroidSqliteDriver(
AppDatabase.Schema,
context as Context,
"app.db"
)
}
}
// iosMain: usa Native SQLite
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}
// CORRETTO: expect per funzionalita OS-specific senza logica business
// commonMain
expect fun getCurrentTimestamp(): Long
expect fun getDeviceLocale(): String
// androidMain
actual fun getCurrentTimestamp(): Long = System.currentTimeMillis()
actual fun getDeviceLocale(): String = java.util.Locale.getDefault().toLanguageTag()
// iosMain
import platform.Foundation.NSDate
import platform.Foundation.NSLocale
actual fun getCurrentTimestamp(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
actual fun getDeviceLocale(): String = NSLocale.currentLocale.localeIdentifier
// SBAGLIATO: expect per logica business (anti-pattern)
// Non fare questo: la logica business deve essere in commonMain
expect fun calculateDiscount(price: Double, percentage: Int): Double
// Questo NON ha senso come expect perche non varia per piattaforma!
Interfaces for Isolating Dependencies
An even cleaner pattern than expect/actual for most cases
and use interfaces in commonMain with platform-specific implementations via Koin.
This approach doesn't require expect/actual and makes the code easier to test with mocks:
// commonMain: interfaccia nel domain layer
interface PlatformFileStorage {
suspend fun readFile(path: String): ByteArray?
suspend fun writeFile(path: String, data: ByteArray)
suspend fun deleteFile(path: String)
suspend fun listFiles(directory: String): List<String>
fun getStorageRoot(): String
}
// commonMain: use case che dipende dall'interfaccia (non dall'implementazione)
class ExportDataUseCase(
private val storage: PlatformFileStorage,
private val serializer: DataSerializer
) {
suspend fun execute(data: AppData, fileName: String): String {
val bytes = serializer.serialize(data)
val path = "${storage.getStorageRoot()}/$fileName"
storage.writeFile(path, bytes)
return path
}
}
// androidMain: implementazione Android
class AndroidFileStorage(private val context: Context) : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.IO) {
runCatching { File(path).readBytes() }.getOrNull()
}
override suspend fun writeFile(path: String, data: ByteArray) =
withContext(Dispatchers.IO) {
File(path).apply { parentFile?.mkdirs() }.writeBytes(data)
}
override fun getStorageRoot(): String = context.filesDir.absolutePath
// ... altri metodi
}
// iosMain: implementazione iOS
class IosFileStorage : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.Default) {
NSData.dataWithContentsOfFile(path)?.toByteArray()
}
override fun getStorageRoot(): String {
val docs = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSUserDomainMask, true
).first() as String
return docs
}
// ... altri metodi
}
Koin for Dependency Injection Multiplatform
Koin is the dependency injection framework best suited to KMP: it is written in Pure Kotlin, doesn't use reflection (crucial for Kotlin/Native on iOS), and has a simple API DSL based. Configuration requires a common module and platform-specific modules:
// commonMain/kotlin/com/example/app/di/CommonModule.kt
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val domainModule = module {
// Use cases: dipendono da interfacce, non da implementazioni
singleOf(::GetUsersUseCase)
singleOf(::SaveUserUseCase)
singleOf(::ExportDataUseCase)
}
val dataModule = module {
// Repository: usa l'interfaccia del domain
single<UserRepository> { UserRepositoryImpl(get(), get()) }
// Network client condiviso
single {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}
}
}
// expect: il modulo platform-specific deve essere fornito da ogni piattaforma
expect val platformModule: Module
// androidMain/kotlin/com/example/app/di/PlatformModule.android.kt
actual val platformModule = module {
// Android Context via androidContext() helper
single<PlatformFileStorage> { AndroidFileStorage(androidContext()) }
single { DatabaseDriverFactory(androidContext()).create() }
single { AppDatabase(get()) }
}
// androidMain: inizializzazione Koin in Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(domainModule, dataModule, platformModule)
}
}
}
// iosMain/kotlin/com/example/app/di/PlatformModule.ios.kt
actual val platformModule = module {
single<PlatformFileStorage> { IosFileStorage() }
single { DatabaseDriverFactory().create() }
single { AppDatabase(get()) }
}
// iosMain: inizializzazione Koin (chiamata dallo Swift AppDelegate o App struct)
fun initKoin() {
startKoin {
modules(domainModule, dataModule, platformModule)
}
}
// iosApp/iOSApp.swift - Inizializzazione da Swift
import SwiftUI
import Shared
@main
struct iOSApp: App {
init() {
// Inizializza Koin all'avvio dell'app iOS
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ViewModel Shared with Kotlin Flows
The shared form's presentation layer uses ViewModel and Kotlin Flows to expose state
responsive to both Android and iOS. Kotlin Flows are available on all platforms
via kotlinx-coroutines-core:
// commonMain: ViewModel condiviso
class UserListViewModel(
private val getUsersUseCase: GetUsersUseCase
) : KoinComponent {
private val _state = MutableStateFlow<UserListState>(UserListState.Loading)
val state: StateFlow<UserListState> = _state.asStateFlow()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun loadUsers() {
coroutineScope.launch {
_state.value = UserListState.Loading
try {
val users = getUsersUseCase.execute()
_state.value = UserListState.Success(users)
} catch (e: Exception) {
_state.value = UserListState.Error(e.message ?: "Errore sconosciuto")
}
}
}
fun onCleared() {
coroutineScope.cancel()
}
}
sealed class UserListState {
object Loading : UserListState()
data class Success(val users: List<User>) : UserListState()
data class Error(val message: String) : UserListState()
}
Testing the Shared Module in Isolation
One of the main advantages of this architecture is testability: the domain code layer has no platform-specific dependencies, so the tests run in the JVM without emulators:
// commonTest: test del use case con mock dell'interfaccia
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
class GetUsersUseCaseTest {
// Mock dell'interfaccia UserRepository
private val mockRepository = object : UserRepository {
override suspend fun getUsers() = listOf(
User(1, "Federico", "federico@example.com"),
User(2, "Marco", "marco@example.com")
)
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
private val useCase = GetUsersUseCase(mockRepository)
@Test
fun testGetUsersReturnsCorrectList() = runTest {
val result = useCase.execute()
assertEquals(2, result.size)
assertEquals("Federico", result[0].name)
}
@Test
fun testGetUsersEmpty() = runTest {
val emptyRepo = object : UserRepository {
override suspend fun getUsers() = emptyList<User>()
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
val result = GetUsersUseCase(emptyRepo).execute()
assertEquals(0, result.size)
}
}
class UserListViewModelTest {
@Test
fun testInitialStateIsLoading() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
// Prima di loadUsers(), lo stato e Loading
assertIs<UserListState.Loading>(viewModel.state.value)
}
@Test
fun testLoadUsersProducesSuccess() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
viewModel.loadUsers()
// Aspetta che il coroutine completi
testScheduler.advanceUntilIdle()
val state = viewModel.state.value
assertIs<UserListState.Success>(state)
assertEquals(2, state.users.size)
}
}
Best Practices for KMP Architecture
- Keep the domain layer pure: no dependencies on Ktor, SQLDelight, Android or iOS. Just pure Kotlin and interfaces.
- Use interfaces for everything that varies by platform: prefer interfaces + Koin to expect/actual for most cases.
- expect/actual for low-level infrastructure: Database drivers, HTTP engine, filesystem access, system API.
- Write all tests in commonTest: tests that run on JVM are much faster than those on iOS simulator. Reserve iOS tests for scenarios that really require the native environment.
- Do not expose coroutines to platforms directly: use wrappers with callbacks or use KMP NativeCoroutines/SKIE for exposure automatic like async/await Swift.
Conclusions and Next Steps
The clean architecture of the shared module — pure domain layer, data layer with interfaces, Koin for DI — and the foundation of a maintainable KMP project. The expect/actual mechanism used only where necessary (platform-specific infrastructure) and interfaces throughout the rest they produce code that is testable, extensible, and understandable by the teams they work on different platforms.
The next article goes into more detail Ktor Client for multiplatform networking: how to configure the HTTP client, manage JWT authentication, implement retry with backoff exponential, and test the API calls with a mock server in commonTest.
Series: Kotlin Multiplatform — One Codebase, All Platforms
- Article 1: KMP in 2026 — Architecture, Establishment and Ecosystem
- Article 2: Configure Your First KMP Project — Android, iOS and Desktop
- Article 3 (this): Shared Module Architecture — expect/actual, Interfaces and DI
- Article 4: Multiplatform Networking with Ktor Client
- Article 5: Multiplatform Persistence with SQLDelight
- Article 6: Compose Multiplatform — Shared UI on Android and iOS
- Article 7: State Management KMP — ViewModel and Kotlin Flows
- Article 8: KMP Testing — Unit Test, Integration Test and UI Test
- Article 9: Swift Export — Idiomatic Interop with iOS
- Article 10: CI/CD for KMP Projects — GitHub Actions and Fastlane
- Article 11: Case Study — Fintech App with KMP in Production







