Your app calls an API. It also reads from a local database. Sometimes the API is down. Sometimes the user has no internet. Where does the data come from?
Without a clear answer, your app becomes unpredictable. One screen shows fresh data from the API. Another screen shows stale data from the database. The user sees different information depending on which screen they check first.
The repository pattern solves this. It gives your app a single source of truth — one place where data lives. Every screen reads from the same source.
Prerequisites: Compose Tutorial #12: Retrofit basics and Compose Tutorial #13: Room basics. Also read Tutorial #1: Architecture for context on Clean Architecture layers.
What is the Repository Pattern?
A repository is a class that manages data from multiple sources and exposes a clean API to the rest of the app.
ViewModel → Repository → Local Database (Room)
→ Remote API (Retrofit)
→ Other sources (DataStore, files)
The ViewModel asks the repository for data. It does not know if the data comes from the internet or the local database. The repository decides.
Why “Single Source of Truth”?
The single source of truth (SSOT) principle says: the local database is the only source the UI reads from.
The API is just a way to update the database. The flow looks like this:
- UI asks for data
- Repository returns data from Room (as a Flow)
- Repository also fetches fresh data from the API in the background
- Repository saves the fresh data to Room
- Room Flow automatically emits the new data to the UI
The UI never reads directly from the API. It always reads from Room.
Basic Repository
Here is a simple repository that combines Room and Retrofit:
Interface (Domain Layer)
interface UserRepository {
fun getUsers(): Flow<List<User>>
suspend fun refreshUsers()
suspend fun deleteUser(userId: Long)
}
The interface lives in the domain layer. It uses domain models, not database entities or API DTOs.
Implementation (Data Layer)
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi
) : UserRepository {
override fun getUsers(): Flow<List<User>> {
return userDao.getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun refreshUsers() {
val remoteUsers = userApi.getUsers()
val entities = remoteUsers.map { it.toEntity() }
userDao.insertAll(entities)
}
override suspend fun deleteUser(userId: Long) {
userDao.deleteById(userId)
try {
userApi.deleteUser(userId)
} catch (_: Exception) {
// Will sync later
}
}
}
Mapper Functions
// data/mapper/UserMappers.kt
fun UserDto.toEntity() = UserEntity(
id = id,
name = name,
email = email,
avatarUrl = avatarUrl
)
fun UserEntity.toDomain() = User(
id = id,
name = name,
email = email,
avatarUrl = avatarUrl
)
Three models, three layers:
UserDto— what the API returns (@Serializable)UserEntity— what Room stores (@Entity)User— what the app uses (domain model)
This separation means changing the API response format only affects the data layer. The ViewModel and UI stay the same.
Offline-First Strategy
An offline-first app shows cached data immediately and refreshes in the background.
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi
) : UserRepository {
override fun getUsers(): Flow<List<User>> {
return userDao.getAll()
.map { entities -> entities.map { it.toDomain() } }
.onStart { refreshIfNeeded() }
}
private suspend fun refreshIfNeeded() {
try {
val remoteUsers = userApi.getUsers()
userDao.insertAll(remoteUsers.map { it.toEntity() })
} catch (_: Exception) {
// No internet — cached data will be shown
}
}
}
When getUsers() is collected:
- Room immediately emits cached data (the user sees something fast)
onStarttriggers a background API call- If the API call succeeds, Room gets updated
- Room Flow automatically emits the fresh data
- If the API call fails, nothing happens — cached data stays visible
The user always sees data, even without internet.
Error Handling with Result
A sealed class Result pattern lets you communicate success, error, and loading states:
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>
data object Loading : Result<Nothing>
}
Using Result in Repository
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi
) : UserRepository {
override fun getUsers(): Flow<Result<List<User>>> = flow {
emit(Result.Loading)
// Emit cached data first
val cached = userDao.getAllOnce().map { it.toDomain() }
if (cached.isNotEmpty()) {
emit(Result.Success(cached))
}
// Try to refresh from API
try {
val remote = userApi.getUsers()
userDao.insertAll(remote.map { it.toEntity() })
val fresh = userDao.getAllOnce().map { it.toDomain() }
emit(Result.Success(fresh))
} catch (e: Exception) {
if (cached.isEmpty()) {
emit(Result.Error("Failed to load users", e))
}
// If we have cached data, we already emitted it
}
}
}
ViewModel Consuming Result
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.getUsers().collect { result ->
_state.update { state ->
when (result) {
is Result.Loading -> state.copy(isLoading = true)
is Result.Success -> state.copy(
users = result.data,
isLoading = false,
error = null
)
is Result.Error -> state.copy(
isLoading = false,
error = result.message
)
}
}
}
}
}
}
NetworkBoundResource
If you find yourself writing the same “cache then network” logic in every repository method, extract it into a reusable function:
inline fun <ResultType, RequestType> networkBoundResource(
crossinline query: () -> Flow<ResultType>,
crossinline fetch: suspend () -> RequestType,
crossinline saveFetchResult: suspend (RequestType) -> Unit,
crossinline shouldFetch: (ResultType) -> Boolean = { true }
): Flow<Result<ResultType>> = flow {
emit(Result.Loading)
val data = query().first()
if (shouldFetch(data)) {
emit(Result.Success(data)) // Show cached data while loading
try {
val fetchedData = fetch()
saveFetchResult(fetchedData)
} catch (e: Exception) {
emit(Result.Error("Network error: ${e.message}", e))
}
}
// Observe database for changes
emitAll(query().map { Result.Success(it) })
}
Using It
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi
) : UserRepository {
override fun getUsers(): Flow<Result<List<User>>> = networkBoundResource(
query = {
userDao.getAll().map { entities ->
entities.map { it.toDomain() }
}
},
fetch = { userApi.getUsers() },
saveFetchResult = { dtos ->
userDao.insertAll(dtos.map { it.toEntity() })
},
shouldFetch = { cachedUsers ->
cachedUsers.isEmpty() // Only fetch if cache is empty
}
)
}
This pattern keeps your repository methods short and consistent.
Caching Strategies
Time-Based Cache
Only fetch from the API if the cache is older than a threshold:
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi,
private val preferences: DataStore<Preferences>
) : UserRepository {
companion object {
private val LAST_FETCH_KEY = longPreferencesKey("users_last_fetch")
private const val CACHE_DURATION_MS = 5 * 60 * 1000L // 5 minutes
}
private suspend fun shouldFetch(): Boolean {
val lastFetch = preferences.data.first()[LAST_FETCH_KEY] ?: 0L
return System.currentTimeMillis() - lastFetch > CACHE_DURATION_MS
}
override fun getUsers(): Flow<Result<List<User>>> = networkBoundResource(
query = { userDao.getAll().map { it.map { e -> e.toDomain() } } },
fetch = { userApi.getUsers() },
saveFetchResult = { dtos ->
userDao.insertAll(dtos.map { it.toEntity() })
preferences.edit { it[LAST_FETCH_KEY] = System.currentTimeMillis() }
},
shouldFetch = { shouldFetch() }
)
}
Manual Invalidation
Let the user trigger a refresh:
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val userApi: UserApi
) : UserRepository {
override fun getUsers(): Flow<List<User>> {
return userDao.getAll().map { it.map { e -> e.toDomain() } }
}
override suspend fun refreshUsers() {
try {
val remote = userApi.getUsers()
userDao.deleteAll()
userDao.insertAll(remote.map { it.toEntity() })
} catch (e: Exception) {
// Rethrow so the caller (ViewModel) can show an error to the user
throw e
}
}
}
The ViewModel calls refresh() when the user pulls to refresh.
Flow-Based Repositories
Room supports Flow natively. When database data changes, the Flow emits automatically.
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY name ASC")
fun getAll(): Flow<List<UserEntity>> // Reactive — emits on changes
@Query("SELECT * FROM users WHERE id = :userId")
fun getById(userId: Long): Flow<UserEntity?> // Also reactive
@Query("SELECT * FROM users ORDER BY name ASC")
suspend fun getAllOnce(): List<UserEntity> // One-shot query
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(users: List<UserEntity>)
@Query("DELETE FROM users WHERE id = :userId")
suspend fun deleteById(userId: Long)
@Query("DELETE FROM users")
suspend fun deleteAll()
}
Use Flow return types for data the UI observes continuously. Use suspend functions for one-shot operations.
Combining Flows
If you need data from multiple tables:
class DashboardRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
private val userDao: UserDao
) : DashboardRepository {
override fun getDashboard(): Flow<Dashboard> {
return combine(
taskDao.getIncompleteTasks(),
userDao.getCurrentUser()
) { tasks, user ->
Dashboard(
pendingTasks = tasks.map { it.toDomain() },
userName = user?.name ?: "Guest"
)
}
}
}
Testing Repositories
Repositories are easy to test because they depend on interfaces (DAO and API).
Fake DAO
class FakeUserDao : UserDao {
private val users = MutableStateFlow<List<UserEntity>>(emptyList())
override fun getAll(): Flow<List<UserEntity>> = users
override suspend fun getAllOnce(): List<UserEntity> = users.value
override suspend fun insertAll(users: List<UserEntity>) {
this.users.value = users
}
override suspend fun deleteById(userId: Long) {
users.value = users.value.filter { it.id != userId }
}
override suspend fun deleteAll() {
users.value = emptyList()
}
}
Fake API
class FakeUserApi : UserApi {
var usersToReturn: List<UserDto> = emptyList()
var shouldThrow: Boolean = false
override suspend fun getUsers(): List<UserDto> {
if (shouldThrow) throw IOException("No internet")
return usersToReturn
}
override suspend fun deleteUser(userId: Long) {
if (shouldThrow) throw IOException("No internet")
}
}
Test
class UserRepositoryImplTest {
private lateinit var repository: UserRepositoryImpl
private lateinit var fakeDao: FakeUserDao
private lateinit var fakeApi: FakeUserApi
@Before
fun setup() {
fakeDao = FakeUserDao()
fakeApi = FakeUserApi()
repository = UserRepositoryImpl(fakeDao, fakeApi)
}
@Test
fun `getUsers returns cached data when API fails`() = runTest {
// Setup cached data
fakeDao.insertAll(listOf(
UserEntity(1, "Alex", "alex@example.com", null)
))
// API will fail
fakeApi.shouldThrow = true
val users = repository.getUsers().first()
assertEquals(1, users.size)
assertEquals("Alex", users.first().name)
}
@Test
fun `refreshUsers saves API data to database`() = runTest {
fakeApi.usersToReturn = listOf(
UserDto(1, "Alex", "alex@example.com", null),
UserDto(2, "Sam", "sam@example.com", null)
)
repository.refreshUsers()
val cached = fakeDao.getAllOnce()
assertEquals(2, cached.size)
}
}
Repository Best Practices
One repository per data type —
UserRepository,TaskRepository,SettingsRepository. Don’t create a god repository.Return domain models — never expose
UserEntityorUserDtoto the ViewModel. Map them toUser.Define interface in the domain layer — the ViewModel depends on the interface, not the implementation.
Use Flow for observable data — the UI reacts to database changes automatically.
Handle errors in the repository — don’t let network exceptions bubble up to the ViewModel without wrapping them in a Result.
Keep repositories stateless — don’t store data in memory fields. Let Room be the state holder.
What’s Next?
In this tutorial, you learned:
- The repository pattern and single source of truth
- How to combine Room and Retrofit in one data layer
- Offline-first strategy with Flow
- Error handling with sealed class Result
- The NetworkBoundResource pattern
- Caching strategies and testing
Next up: Android Tutorial #4: Use Cases and Domain Layer — where you will learn how to extract business logic from ViewModels into reusable use case classes.
Related Articles
- Compose Tutorial #12: Retrofit — API call basics
- Compose Tutorial #13: Room — database basics
- Android Tutorial #1: Architecture — Clean Architecture overview
- Kotlin Tutorial: Flow — reactive streams
This is part 3 of the Android Development Tutorial series.