Repository & Data Layer Explained

ARCHITECTURE › Patterns

The repository is the **entry point to the data layer**, and the only one. Other layers, ViewModels, use cases, never talk to a Retrofit service or Room DAO directly, they go through a repository.

// BAD - ViewModel bypasses the repository
class BadViewModel(private val dao: ArticleDao) : ViewModel()

// GOOD - ViewModel only depends on the repository
class ArticleViewModel(private val repo: ArticleRepository) : ViewModel()

A repository's job is broader than 'fetch data': it exposes data to the app, centralizes changes, resolves conflicts between multiple sources, abstracts away where data actually comes from, and can hold business logic of its own.

The data layer exposes two different shapes depending on the operation:

- A **one-shot** operation (place an order, fetch once) is a suspend function - **Ongoing changes** the caller should keep observing are a Flow

interface OrderRepository {
    suspend fun placeOrder(cart: Cart): Order          // one-shot
    fun observeOrders(): Flow<List<Order>>              // ongoing
}

Mixing these up is a real interview tell: exposing a continuously-updating cache as a suspend function forces the caller to poll, and exposing a true one-shot action as a Flow the caller has to collect (and remember to cancel) is needless ceremony.

When multiple sources (network, cache, database) could answer the same question, the repository designates one **single source of truth**, and a local database like Room is the usual choice.

// Repository writes network response into Room (the single source of truth)
suspend fun refresh() {
    val dtos = networkApi.getArticles()
    articleDao.insertAll(dtos.map { it.toDomain() })
}

// ViewModel always observes Room, not the network directly
val articles: Flow<List<Article>> = articleDao.observeAll()

Room survives process death and supports offline-first reads, so the UI always has a consistent answer even with no network. Fresh data flows *into* the database first, then back out through the same Flow every consumer already observes.

Data crossing into the app from the network shouldn't hand raw DTOs to the UI. A mapper function converts the external shape into an immutable domain model.

// Network DTO - raw API shape
data class ArticleDto(val id: String, val title: String, val internalTag: String)

// Domain model - trimmed to only what the app needs
data class Article(val id: String, val title: String)

fun ArticleDto.toDomain() = Article(id = id, title = title)

This keeps only the fields the app actually needs (saving memory), decouples the UI from the API's shape, and means a backend field rename only touches the mapper, not every screen that reads Article.

Repositories and data sources must be **main-safe**, callable directly from the main thread, with blocking work moved off it internally.

class ArticleRemoteDataSource(private val ioDispatcher: CoroutineDispatcher) {
    suspend fun fetchArticles(): List<ArticleDto> =
        withContext(ioDispatcher) {
            blockingHttpClient.get("/articles")
        }
}

Note the dispatcher is **injected** through the constructor, not hardcoded as Dispatchers.IO. That's what lets a test swap in UnconfinedTestDispatcher() and run everything deterministically on one thread, instead of racing against a real background thread pool.

An in-memory cache that several coroutines read and write concurrently needs a Mutex, not just @Volatile, to stay consistent.

private val mutex = Mutex()
private val cache = mutableMapOf<String, Article>()

suspend fun putArticle(article: Article) = mutex.withLock {
    cache[article.id] = article
}

suspend fun getArticle(id: String): Article? = mutex.withLock {
    cache[id]
}

@Volatile only guarantees that a written value becomes visible to other threads, it says nothing about compound operations like 'check then write' staying atomic. withLock serializes access, so only one coroutine touches cache at a time.

Not every coroutine a repository launches should live for the same length of time. Android's guidance distinguishes three scopes:

- **UI-oriented**: cancelled when the user leaves the screen (viewModelScope) - **App-oriented**: lives as long as the app process (an injected application-scoped CoroutineScope) - **Business-oriented**: must survive process death and can't be cancelled, use WorkManager

// Business-critical sync - must survive process death
WorkManager.getInstance(context).enqueue(
    OneTimeWorkRequestBuilder<SyncWorker>()
        .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
        .build()
)

An analytics upload that should survive screen navigation but not process death fits app-oriented; a payment sync that absolutely must complete fits business-oriented.

Two closing conventions round out repository design.

**Organize by data type, not by screen or by data source.** A MoviesRepository and a PaymentsRepository, each potentially backed by several sources, beats one god AppRepository that owns everything, or a fresh repository per screen.

// GOOD - one repository per data type
class MoviesRepository(private val dao: MovieDao, private val api: MovieApi)
class PaymentsRepository(private val dao: PaymentDao, private val api: PaymentApi)

**Conflict resolution between sources lives in the repository, not the ViewModel.** If the network and the cache disagree, the repository decides which one wins, keeping that policy out of the UI layer entirely so it stays reusable and testable in isolation.

Back to Repository & Data Layer