androidengineers.Book a session

Cross-Platform Considerations

Exercise: KMM Module POC

exercise60 minHard

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

  • shared Gradle module with commonMain, androidMain, iosMain
  • Ktor for network in commonMain
  • SQLDelight for caching in commonMain
  • GetArticlesUseCase exposed as a Flow<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)

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: KMM Module POC | Android System Design | Android Engineers