Repository & Data Layer Explained

ARCHITECTURE › Patterns

In Android's recommended architecture, the repository class is the single entry point to the data layer. A ViewModel, a use case, any class sitting above the data layer, none of them call a Retrofit service or a Room DAO directly. They call the repository, and the repository decides where the answer actually comes from. That's not just a naming convention, it's the property interviewers are checking for. A repository's job is broader than fetching data. It exposes data to the rest of the app, centralizes changes to that data, resolves conflicts when multiple sources disagree, abstracts away where the data physically lives, and can hold business logic of its own. Skip any one of those and you've built a thin wrapper around a DAO, not a repository.

// BAD: ViewModel bypasses the repository and touches the data source directly
class BadViewModel(private val dao: ArticleDao) : ViewModel()

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

The data layer exposes two different shapes depending on what the operation actually does. A one-shot operation, placing an order, fetching a value once, saving a setting, is exposed as a suspend function: call it, get a result back, done. Ongoing changes the caller needs to keep observing, a list that updates as the cache changes, a live order status, are exposed as a Flow, a cold stream that emits a new value every time the underlying data changes. Mixing these up is a real interview tell. Exposing a continuously updating cache as a suspend function forces the caller to poll it over and over. Exposing a true one-shot action as a Flow makes the caller collect it and remember to cancel, for something that only ever emits once. The question to ask about any repository method is simple: does the caller need to know about this changing over time, or do they just need the answer right now?

interface OrderRepository {
    // One-shot: place an order and return the result once
    suspend fun placeOrder(cart: Cart): Order

    // Ongoing: observe order status changes over time
    fun observeOrders(): Flow<List<Order>>
}

When more than one source, network, in-memory cache, database, could all answer the same question, the repository has to pick exactly one of them as the single source of truth. For most apps that source is a local database like Room. It's not chosen because it's the fastest option, an in-memory cache usually wins on raw speed. It's chosen because it survives process death and keeps working offline. If the app process gets killed and restarted, a database still has yesterday's data on disk; an in-memory cache does not. And because every read goes through that one database, the app never has to reconcile two different answers to the same question, whatever's on disk is, by definition, the current answer.

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun observeAll(): Flow<List<Article>>  // persisted to disk, survives process death

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(articles: List<Article>)
}

Once Room is the single source of truth, fresh network data can't be handed straight to the UI. It has to go through the database first. A refresh function fetches from the network, writes the result into Room, and the UI, which is already observing a Flow from Room, picks up the change automatically on the next emission. The UI never reads from the network directly, and it never needs a separate code path for data that just arrived from the network versus data that was already cached. There's exactly one path: write to the database, observe the database. That's what single source of truth actually buys you in practice, not just a rule about where to read from, but a guarantee that the UI is always looking at one consistent picture regardless of whether the last write came from the network or was already sitting on disk.

// 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, never the network directly
val articles: Flow<List<Article>> = articleDao.observeAll()

When the network's answer and the cached answer disagree, something has to decide which one wins, or how to merge them. That decision does not belong in the ViewModel, and it does not belong in the Composable that renders the screen. It belongs in the repository. This is one of the core responsibilities from earlier: centralizing changes and resolving conflicts between sources. Putting that policy anywhere else means every screen that touches this data has to reimplement the same merge logic, and it means you can't unit test the conflict resolution without spinning up UI. Keep it in one place, behind the same interface every consumer already calls.

class ArticleRepository(private val network: ArticleRemoteSource, private val local: ArticleLocalSource) {
    suspend fun refresh() {
        val remote = network.fetch()
        val cached = local.getAll()
        // Conflict resolution lives here, not in the UI
        val resolved = remote.map { remoteItem ->
            cached.find { it.id == remoteItem.id }?.merge(remoteItem) ?: remoteItem
        }
        local.saveAll(resolved)
    }
}

Data arriving from the network has a shape the API decided on, not a shape the app needs. A DTO might carry internal tags, redundant fields, or names that don't match how the rest of the app thinks about the data. The data layer maps that DTO into a separate domain model before anything else sees it. This trims the object down to only the fields the app actually uses, which saves memory, and it means a backend rename or an added field only touches the mapper function, not every screen that reads the domain type. It also means the UI is never coupled to the API's shape, if the backend team changes the DTO tomorrow, nothing outside the data layer has to change.

// Network DTO, raw shape returned by the API
data class ArticleDto(val id: String, val title: String, val bodyHtml: String, val authorName: String, val internalTag: String)

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

// Mapper lives in the data layer; UI never sees ArticleDto
fun ArticleDto.toDomain() = Article(id, title, authorName)

The domain models a repository hands out should be immutable, every field a val, never a var. Two things break if they're not. First, thread safety: a Flow from Room can be collected on one thread while some other part of the app holds a reference to the same object on another thread. If that object is mutable, one side can change it out from under the other, and now you're debugging a race condition instead of a feature. Second, correctness: immutable data can't be tampered with by a class that only meant to read it. Once you hand out an Article, you know its title isn't going to change unless you explicitly create a new one. That guarantee is what makes it safe to share the same instance across threads without a lock.

// GOOD: all fields are val, safe to share across threads
data class Article(
    val id: String,
    val title: String,
    val publishedAt: Instant
)

// BAD: mutable state can be corrupted by another thread
class MutableArticle(var title: String, var publishedAt: Instant)

A repository or data source has to be safe to call directly from the main thread, that's what main-safe means. If a data source needs to call a blocking network library, or run heavy parsing, that work has to move off the calling thread internally, not be the caller's problem. The standard tool is withContext, wrapping the blocking call so it runs on an IO dispatcher and suspends the caller until it's done, without blocking that caller's thread. Note where the responsibility sits: the data source moves the work off-thread itself. It doesn't push that requirement onto whoever calls it, and it doesn't run on Dispatchers.Main and hope for the best. Room, Retrofit, and Ktor's suspend APIs are already main-safe for exactly this reason, you can call them straight from a ViewModel without wrapping anything yourself.

class ArticleRemoteDataSource(private val ioDispatcher: CoroutineDispatcher) {
    suspend fun fetchArticles(): List<ArticleDto> =
        withContext(ioDispatcher) {
            blockingHttpClient.get("/articles")  // blocking call moved off the caller's thread
        }
}

That IO dispatcher shouldn't be hardcoded as Dispatchers.IO inside the function. It should be passed in through the constructor. Hardcoding it couples the class to a real thread pool, so a unit test calling that function either has to deal with real background threads, or hang waiting on work that was never meant to run synchronously. Inject the dispatcher instead, and a test can substitute something like UnconfinedTestDispatcher, which runs everything on one thread in a predictable order. Same production code, same withContext call, but now the test controls exactly when that coroutine runs instead of racing against it.

// Hardcoded: couples the class to a real thread pool, tests are non-deterministic
class BadRepo { suspend fun fetch() = withContext(Dispatchers.IO) { /* ... */ } }

// Injected: swap in a test dispatcher for deterministic unit tests
class ArticleRepository(private val ioDispatcher: CoroutineDispatcher) {
    suspend fun fetch() = withContext(ioDispatcher) { /* ... */ }
}

// In tests:
val repo = ArticleRepository(ioDispatcher = UnconfinedTestDispatcher())

An in-memory cache that several coroutines read and write at the same time needs more protection than the @Volatile keyword gives it. @Volatile only guarantees that a value written on one thread becomes visible to another thread, it says nothing about a compound operation, like check whether a key exists, then write it, staying atomic. Two coroutines can both check, both find nothing there, and both write, and one of those writes silently overwrites the other. The fix is a Mutex. Wrapping every read and every write in withLock means only one coroutine is ever inside that block at a time, so the check-then-write sequence can't be interleaved by a second coroutine.

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

suspend fun getArticle(id: String): Article? = mutex.withLock {
    cache[id]  // only one coroutine enters at a time, no corruption
}

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

Not every coroutine a repository launches should live for the same length of time. Android's guidance splits operations into three scopes. UI-oriented operations are cancelled the moment the user leaves the screen, that's viewModelScope. App-oriented operations should keep running as long as the app process is alive, even if the user navigates away from the screen that started them, that calls for an injected application-scoped CoroutineScope. Business-oriented operations are the strictest: they must survive process death entirely, and they can't be cancelled. A payment sync or a critical data upload falls into this category, and the tool for it is WorkManager, which schedules the work to run even if Android kills the app in the meantime.

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

Contrast that with an analytics event upload. It should keep going even if the user backs out of the screen that triggered it, so viewModelScope, which gets cancelled on navigation, is the wrong tool. But it doesn't need to survive the entire app process dying either, missing one analytics event if the OS kills the app is not a business emergency, so WorkManager is overkill for it. This is the middle scope: app-oriented. An injected application-scoped CoroutineScope lives exactly as long as the app process does, which is precisely the lifetime this task needs, no more, no less.

class AnalyticsRepository @Inject constructor(
    private val api: AnalyticsApi,
    @ApplicationScope private val appScope: CoroutineScope  // lives as long as the process
) {
    fun logEvent(event: AnalyticsEvent) {
        appScope.launch { api.upload(event) }  // completes even after the user navigates away
    }
}

One last convention shapes how repositories get organized across an app, and it's a common one to get wrong. The recommendation is one repository per type of data, a MoviesRepository, a PaymentsRepository, each of those potentially backed by several data sources of its own. That beats two other tempting designs. A single god AppRepository that owns every data type in the app becomes an unmaintainable dumping ground, and a change to how movies are cached risks breaking payments code sitting in the same class. A repository created fresh per screen goes the opposite direction, duplicating the same data access logic across every screen that happens to need articles, and losing the single source of truth that made a repository worth having in the first place.

// GOOD: one repository per data type, each with its own sources
class MoviesRepository(private val dao: MovieDao, private val api: MovieApi)
class PaymentsRepository(private val dao: PaymentDao, private val api: PaymentApi)

// BAD: god repository owns every data type in the app
class AppRepository(movieDao: MovieDao, paymentDao: PaymentDao, userDao: UserDao /*...*/)

Back to Repository & Data Layer