androidengineers.Book a session

Cross-Platform Considerations

KMM: Shared Domain/Data Layers

article25 minHard

Kotlin Multiplatform Mobile (KMM) lets you share business logic across Android and iOS while keeping platform UIs native. The strategy: share domain models, repository interfaces, and use cases; leave ViewModel and UI platform-specific.

What to Share

Shared (commonMain):
├── domain/
│   ├── model/          Article.kt, User.kt
│   ├── repository/     ArticleRepository.kt (interface)
│   └── usecase/        GetArticlesUseCase.kt
├── data/
│   ├── remote/         ArticleApi.kt (Ktor)
│   ├── cache/          ArticleCache.kt (SQLDelight)
│   └── repository/     ArticleRepositoryImpl.kt
└── util/
    └── DateFormatter.kt

Android-specific (androidMain):
├── DI (Hilt modules)
├── ViewModel
└── Compose UI

iOS-specific (iosMain):
├── DI (manual or Koin)
├── ObservableObject wrappers
└── SwiftUI

Shared Domain Layer

// commonMain/domain/model/Article.kt
data class Article(
    val id: String,
    val title: String,
    val body: String,
    val publishedAt: Long,
    val authorName: String
)

// commonMain/domain/repository/ArticleRepository.kt
interface ArticleRepository {
    fun observeArticles(): Flow<List<Article>>
    suspend fun refreshArticles()
    suspend fun getArticle(id: String): Article?
}

// commonMain/domain/usecase/GetArticlesUseCase.kt
class GetArticlesUseCase(private val repository: ArticleRepository) {
    operator fun invoke(): Flow<List<Article>> = repository.observeArticles()
        .map { articles -> articles.sortedByDescending { it.publishedAt } }
}

Shared Data Layer (Ktor + SQLDelight)

// commonMain/data/remote/ArticleApi.kt
class ArticleApi(private val client: HttpClient) {
    suspend fun fetchArticles(): List<ArticleDto> {
        return client.get("https://api.example.com/articles").body()
    }
}

// Ktor HttpClient factory (configured per platform in expect/actual)
// commonMain expects a factory; androidMain/iosMain provides platform engine

// commonMain/data/repository/ArticleRepositoryImpl.kt
class ArticleRepositoryImpl(
    private val api: ArticleApi,
    private val queries: ArticleQueries  // SQLDelight generated
) : ArticleRepository {

    override fun observeArticles(): Flow<List<Article>> {
        return queries.selectAll()
            .asFlow()
            .mapToList(Dispatchers.Default)
            .map { it.map { row -> row.toDomain() } }
    }

    override suspend fun refreshArticles() {
        val dtos = api.fetchArticles()
        queries.transaction {
            dtos.forEach { dto ->
                queries.upsert(
                    id = dto.id,
                    title = dto.title,
                    body = dto.body,
                    published_at = dto.publishedAt,
                    author_name = dto.authorName
                )
            }
        }
    }
}

Platform-Specific DI

Android (Hilt module)

@Module
@InstallIn(SingletonComponent::class)
object SharedModule {

    @Provides @Singleton
    fun provideHttpClient(): HttpClient = HttpClient(OkHttp) {
        install(ContentNegotiation) { json() }
        install(Logging) { level = LogLevel.INFO }
    }

    @Provides @Singleton
    fun provideArticleApi(client: HttpClient) = ArticleApi(client)

    @Provides @Singleton
    fun provideArticleRepository(
        api: ArticleApi,
        database: AppDatabase
    ): ArticleRepository = ArticleRepositoryImpl(api, database.articleQueries)

    @Provides
    fun provideGetArticlesUseCase(repository: ArticleRepository) =
        GetArticlesUseCase(repository)
}

iOS (Koin or Manual)

// SwiftUI — wrap Flow as @Published using Kotlin coroutines/concurrency bridge
class ArticlesObservable: ObservableObject {
    @Published var articles: [Article] = []
    private let useCase: GetArticlesUseCase

    init(useCase: GetArticlesUseCase) {
        self.useCase = useCase
        observeArticles()
    }

    private func observeArticles() {
        // Use KMM's FlowCollector bridge
        useCase.invoke().collect { articles in
            DispatchQueue.main.async {
                self.articles = articles as! [Article]
            }
        }
    }
}

expect/actual for Platform APIs

// commonMain
expect fun currentTimeMillis(): Long
expect fun formatDate(timestamp: Long): String

// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun formatDate(timestamp: Long): String =
    SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(Date(timestamp))

// iosMain
actual fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds()
actual fun formatDate(timestamp: Long): String =
    NSDateFormatter().also {
        it.dateFormat = "MMM d, yyyy"
    }.stringFromDate(NSDate(timeIntervalSince1970 = timestamp / 1000.0))

Key Takeaways

LayerShared?Why
Domain modelsYesPure data; no platform dependency
Repository interfacesYesDefine contract; impl uses platform-neutral libraries
Use casesYesPure business logic with coroutines
Network (Ktor)YesMultiplatform HTTP client
Database (SQLDelight)YesGenerates type-safe Kotlin from SQL
ViewModelNoAndroid-specific lifecycle; iOS uses ObservableObject
UINoCompose (Android) / SwiftUI (iOS)

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
KMM: Shared Domain/Data Layers | Android System Design | Android Engineers