Use when implementing the data layer in Android — Repository pattern, Room local database, offline-first synchronization, and coordinating local and remote sources.
The data layer coordinates data from multiple sources. Its public API to the rest of the app is repository interfaces; its internal implementation details (DAOs, API services, DTOs) never leak upward.
The repository is the single source of truth. It decides whether to serve cached data or fetch fresh data, and maps raw data-layer types to domain models.
class NewsRepository @Inject constructor(
private val newsDao: NewsDao,
private val newsApi: NewsApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
// Room DAO as the source of truth — UI always reads from local DB
val newsStream: Flow<List<News>> = newsDao.getAllNews()
// Triggered by UI or WorkManager to refresh data
suspend fun refreshNews(): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val remoteNews = newsApi.fetchLatest()
newsDao.insertAll(remoteNews.map { it.toDomain() })
}
}
}
Bind the interface to its implementation in a Hilt module:
@Binds
abstract fun bindNewsRepository(impl: OfflineFirstNewsRepository): NewsRepository
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val body: String,
val publishedAt: Long
)
Return Flow<T> for observable queries; suspend fun for one-shot reads and writes.
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
fun observeAll(): Flow<List<ArticleEntity>>
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun findById(id: String): ArticleEntity?
@Upsert
suspend fun upsertAll(articles: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun deleteAll()
}
@Database(entities = [ArticleEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}
Provide as a singleton via Hilt and export the schema for migration history tracking.
Show local data immediately; trigger a background refresh in parallel.
// In ViewModel
fun loadNews() {
viewModelScope.launch {
// 1. Start observing the local DB immediately
repository.newsStream
.collect { articles -> _uiState.update { it.copy(articles = articles) } }
}
viewModelScope.launch {
// 2. Trigger a network refresh in parallel
repository.refreshNews().onFailure { error ->
_uiState.update { it.copy(error = error.message) }
}
}
}
Save changes locally first, then sync to the server. Use WorkManager to guarantee delivery even if the app is killed.
// 1. Mark item as unsynced in the DB immediately
suspend fun likeArticle(id: String) {
articleDao.markAsUnsynced(id, action = "LIKE")
}
// 2. WorkManager job (runs when connected)
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val articleDao: ArticleDao,
private val newsApi: NewsApi
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val unsynced = articleDao.getUnsyncedActions()
unsynced.forEach { action ->
newsApi.postAction(action)
articleDao.markAsSynced(action.id)
}
return Result.success()
}
}
Keep three distinct model types and map between them at layer boundaries:
| Layer | Model Type | Purpose |
|---|---|---|
| Network | DTO (ArticleDto) | Matches API JSON structure |
| Database | Entity (ArticleEntity) | Matches Room table schema |
| Domain/UI | Domain model (Article) | What the rest of the app uses |
// DTO → Entity (in repository, before writing to DB)
fun ArticleDto.toEntity(): ArticleEntity = ArticleEntity(
id = id,
title = title,
body = body,
publishedAt = publishedAt
)
// Entity → Domain model (in repository, before returning to ViewModel)
fun ArticleEntity.toDomain(): Article = Article(
id = id,
title = title,
body = body,
publishedAt = Instant.ofEpochMilli(publishedAt)
)
Flow for streams and suspend fun returning Result<T> for one-shot operationsFlow for observed queries; suspend for mutationsexportSchema = true) and migration scripts provided for version bumps