Gedeelde modulearchitectuur in KMP: verwachten/actueel, interfaces en afhankelijkheidsinjectie met Koin
De gedeelde modulearchitectuur in een Kotlin Multiplatform-project en het verschil daartussen
een onderhoudbare applicatie die in de loop van de tijd schaalt en een puinhoop aan code die moeilijk te testen is
en bewerken. Het mechanisme expect/actual en krachtig, maar vereist discipline
correct gebruikt: elke expect Een slechte positie creëert afhankelijkheden die moeilijk te beheren zijn.
Deze handleiding laat zien hoe u het gedeelde formulier kunt structureren door de SOLID-principes in de context toe te passen multiplatform: hoe interfaces te gebruiken om platformspecifieke afhankelijkheden te isoleren, hoe te configureren Koin voor afhankelijkheidsinjectie die werkt op Android en iOS, en hoe je testbare code kunt krijgen geïsoleerd – de sleutel tot een robuust testpakket.
Wat je gaat leren
- Laagarchitectuur voor de gedeelde module: domein, data, presentatie
- verwachten/actueel: correcte patronen en antipatronen die u moet vermijden
- Interfaces om platformspecifieke afhankelijkheden te isoleren
- Koin voor injectie van afhankelijkheid op meerdere platforms
- De gedeelde module afzonderlijk testen
Laagarchitectuur van de gedeelde module
Voordat we het hebben over verwachten/actueel, is het belangrijk om de structuur van de gedeelde module te definiëren. Een strakke architectuur verdeelt de verantwoordelijkheden in drie goed gedefinieerde lagen, elk met: de afhankelijkheidsregels:
// 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 Afhankelijkheidsregel fundamenteel: elke laag kan alleen afhankelijk zijn van lagen interieur. De domeinlaag weet niets van Ktor, SQLDelight of Android/iOS. De datalaag implementeert de domeinlaaginterfaces. De presentatielaag maakt gebruik van de gebruiksscenario's van het domein.
Het juiste patroon van verwachten/actueel
expect/actual voor moet worden gebruikt implementaties platformspecifiek,
niet voor zakelijke API's. De juiste plek voor expect en in de infrastructuurdelen
(databasestuurprogramma's, HTTP-engine, toegang tot bestandssysteem), niet in sjablonen of gebruiksscenario's.
// 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 voor het isoleren van afhankelijkheden
Een nog schoner patroon dan expect/actual voor de meeste gevallen
en gebruik interfaces in commonMain met platformspecifieke implementaties via Koin.
Deze aanpak vereist geen verwachting/actueel en maakt de code eenvoudiger te testen met 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 voor afhankelijkheidsinjectie op meerdere platforms
Koin is het raamwerk voor afhankelijkheidsinjectie dat het meest geschikt is voor KMP: het is geschreven Pure Kotlin, maakt geen gebruik van reflectie (cruciaal voor Kotlin/Native op iOS) en heeft een eenvoudige API DSL-gebaseerd. Voor de configuratie zijn een gemeenschappelijke module en platformspecifieke modules vereist:
// 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 gedeeld met Kotlin-stromen
De presentatielaag van het gedeelde formulier gebruikt ViewModel en Kotlin Flows om de status zichtbaar te maken
reageert op zowel Android als iOS. Kotlin Flows zijn beschikbaar op alle 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()
}
De gedeelde module geïsoleerd testen
Een van de belangrijkste voordelen van deze architectuur is de testbaarheid: de domeincode laag heeft geen platformspecifieke afhankelijkheden, dus worden de tests uitgevoerd in de JVM zonder 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 voor KMP-architectuur
- Houd de domeinlaag puur: geen afhankelijkheden van Ktor, SQLDelight, Android of iOS. Gewoon pure Kotlin en interfaces.
- Gebruik interfaces voor alles wat per platform verschilt: geef de voorkeur aan interfaces + Koin die in de meeste gevallen verwacht/werkelijk zijn.
- verwachten/actueel voor infrastructuur op laag niveau: Databasestuurprogramma's, HTTP-engine, toegang tot bestandssysteem, systeem-API.
- Schrijf alle tests in commonTest: tests die op JVM draaien zijn veel sneller dan die op de iOS-simulator. Reserveer iOS-tests voor scenario's die echt de inheemse omgeving vereisen.
- Stel coroutines niet rechtstreeks bloot aan platforms: gebruik wrappers met callbacks of gebruik KMP NativeCoroutines/SKIE voor exposure automatisch zoals async/wacht op Swift.
Conclusies en volgende stappen
De strakke architectuur van de gedeelde module – pure domeinlaag, datalaag met interfaces, Koin voor DI — en de basis van een onderhoudbaar KMP-project. Het verwacht/actueel-mechanisme alleen gebruikt waar nodig (platformspecifieke infrastructuur) en interfaces in de hele wereld Voor de rest produceren ze code die testbaar, uitbreidbaar en begrijpelijk is voor de teams waaraan ze werken verschillende platforms.
Het volgende artikel gaat hier dieper op in Ktor Client voor multiplatform-netwerken: hoe u de HTTP-client configureert, JWT-authenticatie beheert en nieuwe pogingen met uitstel implementeert exponentieel, en test de API-aanroepen met een nepserver in commonTest.
Serie: Kotlin Multiplatform – Eén Codebase, alle platforms
- Artikel 1: KMP in 2026 — Architectuur, vestiging en ecosysteem
- Artikel 2: Configureer uw eerste KMP-project — Android, iOS en desktop
- Artikel 3 (dit): Gedeelde modulearchitectuur - verwacht/actueel, interfaces en DI
- Artikel 4: Multiplatformnetwerken met Ktor Client
- Artikel 5: Volharding op meerdere platforms met SQLDelight
- Artikel 6: Stel multiplatform samen – gedeelde gebruikersinterface op Android en iOS
- Artikel 7: Staatsbeheer KMP — ViewModel en Kotlin Flows
- Artikel 8: KMP-testen – Unittest, Integratietest en UI-test
- Artikel 9: Swift Export – Idiomatische interoperabiliteit met iOS
- Artikel 10: CI/CD voor KMP-projecten — GitHub Actions en Fastlane
- Artikel 11: Case Study — Fintech-app met KMP in productie







