Build a self-contained KMM module that fetches and caches a list of articles. The shared module exposes a GetArticlesUseCase that Android consumes via ViewModel and iOS could consume via an ObservableObject.
Goal
sharedGradle module withcommonMain,androidMain,iosMain- Ktor for network in
commonMain - SQLDelight for caching in
commonMain GetArticlesUseCaseexposed as aFlow<List<Article>>- Android ViewModel consumes the use case
- All logic covered by
commonTest
Project Structure
app/
shared/
src/
commonMain/kotlin/com/myapp/shared/
├── domain/
│ ├── Article.kt
│ ├── ArticleRepository.kt
│ └── GetArticlesUseCase.kt
├── data/
│ ├── ArticleApi.kt
│ ├── ArticleRepositoryImpl.kt
│ └── DatabaseDriverFactory.kt (expect/actual)
└── di/
└── SharedModule.kt
commonTest/kotlin/com/myapp/shared/
└── GetArticlesUseCaseTest.kt
androidMain/kotlin/
└── DatabaseDriverFactory.kt (actual)
iosMain/kotlin/
└── DatabaseDriverFactory.kt (actual)
Step 1: shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.squareup.sqldelight")
id("com.android.library")
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("com.squareup.sqldelight:runtime:1.5.5")
implementation("com.squareup.sqldelight:coroutines-extensions:1.5.5")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.7")
implementation("com.squareup.sqldelight:android-driver:1.5.5")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
implementation("com.squareup.sqldelight:native-driver:1.5.5")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
implementation("app.cash.turbine:turbine:1.0.0")
}
}
}
}
sqldelight {
database("AppDatabase") {
packageName = "com.myapp.shared.cache"
}
}
Step 2: Article Domain
// commonMain/domain/Article.kt
data class Article(
val id: String,
val title: String,
val body: String,
val publishedAt: Long,
val authorName: String
)
// commonMain/domain/ArticleRepository.kt
interface ArticleRepository {
fun observeArticles(): Flow<List<Article>>
suspend fun refresh()
}
// commonMain/domain/GetArticlesUseCase.kt
class GetArticlesUseCase(private val repository: ArticleRepository) {
operator fun invoke(): Flow<List<Article>> =
repository.observeArticles()
.map { it.sortedByDescending(Article::publishedAt) }
}
Step 3: Data Layer
// commonMain/data/ArticleApi.kt
@Serializable
data class ArticleDto(
val id: String,
val title: String,
val body: String,
@SerialName("published_at") val publishedAt: Long,
@SerialName("author_name") val authorName: String
)
class ArticleApi(private val httpClient: HttpClient) {
suspend fun fetchArticles(): List<ArticleDto> =
httpClient.get("https://api.example.com/articles").body()
}
// commonMain/data/ArticleRepositoryImpl.kt
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val database: AppDatabase
) : ArticleRepository {
override fun observeArticles(): Flow<List<Article>> =
database.articleQueries.selectAll()
.asFlow()
.mapToList(Dispatchers.Default)
.map { rows -> rows.map { it.toDomain() } }
override suspend fun refresh() {
val dtos = api.fetchArticles()
database.articleQueries.transaction {
dtos.forEach { dto ->
database.articleQueries.upsert(
id = dto.id,
title = dto.title,
body = dto.body,
published_at = dto.publishedAt,
author_name = dto.authorName
)
}
}
}
}
// expect/actual for SQLDelight driver
expect class DatabaseDriverFactory {
fun create(): SqlDriver
}
Step 4: commonTest
class GetArticlesUseCaseTest {
private val fakeArticles = listOf(
Article("1", "Old Article", "", 1000L, "Alice"),
Article("2", "New Article", "", 2000L, "Bob")
)
private val fakeRepo = object : ArticleRepository {
override fun observeArticles(): Flow<List<Article>> = flowOf(fakeArticles)
override suspend fun refresh() {}
}
@Test
fun `articles are sorted newest first`() = runTest {
val useCase = GetArticlesUseCase(fakeRepo)
useCase().test {
val items = awaitItem()
assertEquals("New Article", items.first().title)
assertEquals("Old Article", items.last().title)
cancelAndIgnoreRemainingEvents()
}
}
}
Step 5: Android ViewModel
@HiltViewModel
class ArticlesViewModel @Inject constructor(
private val getArticles: GetArticlesUseCase,
private val repository: ArticleRepository
) : ViewModel() {
val articles: StateFlow<List<Article>> = getArticles()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
viewModelScope.launch {
repository.refresh()
}
}
}
Verification Checklist
[ ] ./gradlew :shared:testDebugUnitTest → all tests pass
[ ] ./gradlew :shared:iosSimulatorArm64Test → commonTest passes on iOS target
[ ] App launches → articles fetch and display
[ ] Kill + reopen → cached articles shown before network response
[ ] No imports from android.* in commonMain (use grep to verify)