A ViewModel should not know whether data comes from the network or a database. The repository is a single source of truth that hides data sources behind a clean API — the seam that makes apps testable.
A repository is a class that owns access to a type of data and decides where it comes from — cache, database, or network — exposing a clean, source-agnostic API to the rest of the app. The ViewModel asks the repository for users and does not care how they are fetched. This single source of truth centralizes data logic and keeps the UI layer ignorant of persistence and networking details.
ViewModel ("give me the users")
|
v
UserRepository <- SINGLE SOURCE OF TRUTH; decides where data comes from
/ \
LocalSource RemoteSource
(Room DB) (Retrofit API)
The ViewModel never touches Room or Retrofit directly. It asks the repository,
which coordinates cache + network. Swap a source (or fake it in tests) without
touching the ViewModel or UI.Define the repository as an interface describing what data operations exist, with a concrete implementation that wires up the real data sources. Depending on the interface (not the implementation) is what lets you substitute a fake in tests and change data sources freely. This is dependency inversion applied to the data layer, and it is the seam that makes the app testable.
// the contract — the rest of the app depends on THIS, not the implementation
interface UserRepository {
suspend fun getUsers(): List<User>
suspend fun refresh()
}
// the real implementation coordinates the data sources
class DefaultUserRepository(
private val api: UserApi, // Retrofit (networking lesson)
private val dao: UserDao // Room (storage lesson)
) : UserRepository {
override suspend fun getUsers(): List<User> = dao.getAll()
override suspend fun refresh() {
val fresh = api.fetchUsers() // network
dao.insertAll(fresh) // update the local source of truth
}
}The strongest pattern is offline-first: the database is the single source of truth, the UI always reads from it (as a Flow), and the network merely updates the database. The screen shows cached data instantly and updates automatically when a refresh writes new data to the database. This gives a fast, resilient app that works offline — the gold standard for data flow on Android.
class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
// UI observes the DATABASE as a Flow -> always shows cached data instantly
fun observeUsers(): Flow<List<User>> = dao.observeAll()
// network writes to the DB; the Flow above re-emits automatically
override suspend fun refresh() {
val fresh = api.fetchUsers()
dao.insertAll(fresh) // -> observeUsers() emits the new list -> UI updates
}
}
// offline-first: read from the DB (works offline, instant); network just
// refreshes the DB. The reactive Flow keeps the UI in sync. Gold standard.Each layer often has its own model: a network DTO shaped by the API, a database entity shaped for storage, and a domain model the UI uses. Mapping between them keeps a change in the API from rippling into your UI and lets each layer have the shape it needs. It is a little extra code that buys real decoupling as the app and its APIs evolve.
// network DTO (matches the API's JSON) — not what the UI should depend on
data class UserDto(val user_id: Int, val full_name: String)
// domain model the app uses (clean, UI-friendly)
data class User(val id: Int, val name: String)
// map at the boundary so an API change doesn't ripple into the UI
fun UserDto.toDomain() = User(id = user_id, name = full_name)
// repository converts DTO -> domain before returning
suspend fun getUsers(): List<User> = api.fetchUsers().map { it.toDomain() }
// separate DTO / entity / domain models -> each layer changes independently.