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:

  1. UI asks for data
  2. Repository returns data from Room (as a Flow)
  3. Repository also fetches fresh data from the API in the background
  4. Repository saves the fresh data to Room
  5. 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:

  1. Room immediately emits cached data (the user sees something fast)
  2. onStart triggers a background API call
  3. If the API call succeeds, Room gets updated
  4. Room Flow automatically emits the fresh data
  5. 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

  1. One repository per data typeUserRepository, TaskRepository, SettingsRepository. Don’t create a god repository.

  2. Return domain models — never expose UserEntity or UserDto to the ViewModel. Map them to User.

  3. Define interface in the domain layer — the ViewModel depends on the interface, not the implementation.

  4. Use Flow for observable data — the UI reacts to database changes automatically.

  5. Handle errors in the repository — don’t let network exceptions bubble up to the ViewModel without wrapping them in a Result.

  6. 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.



This is part 3 of the Android Development Tutorial series.