Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 093 / 100

Repository pattern on Android in Modern Android & Kotlin

One API for local and remote data.

Expona Admin7 min read

Repository pattern on Android in Modern Android & Kotlin

When your app needs to fetch user profiles from an API, cache them locally, and refresh them on demand, you quickly realize that spreading data logic across ViewModels creates a maintenance nightmare. The repository pattern solves this by creating a single, unified interface for all data operations—whether the data comes from your Room database, a Retrofit API, or anywhere else. This approach has become the standard in production Android apps because it keeps your code organized, testable, and resilient to changes in data sources.

Single source of truth — Expose data from Room as Flow

The foundation of a solid repository is treating your local database as the single source of truth. When a ViewModel or UI layer needs data, it always reads from Room, never from an in-memory cache or API response. Room queries return Flow<T>, which gives you reactive updates whenever the database changes—exactly what modern Android development demands.

Consider a contacts app where you need to display a list of users. Your Room DAO exposes the query as a Flow:

kotlin
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :userId")
    fun observeUser(userId: String): Flow<User?>

    @Query("SELECT * FROM users ORDER BY name ASC")
    fun observeAllUsers(): Flow<List<User>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertUser(user: User)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertUsers(users: List<User>)
}

Your repository then exposes these queries directly or wraps them lightly:

kotlin
class UserRepository(private val userDao: UserDao) {
    fun observeUser(userId: String): Flow<User?> {
        return userDao.observeUser(userId)
    }

    fun observeAllUsers(): Flow<List<User>> {
        return userDao.observeAllUsers()
    }

    suspend fun upsertUser(user: User) {
        userDao.upsertUser(user)
    }
}

Now your ViewModel simply collects from the repository's Flow. When data changes in the database, the ViewModel receives an update automatically:

kotlin
class UserListViewModel(private val userRepository: UserRepository) : ViewModel() {
    val users = userRepository.observeAllUsers()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}

This approach guarantees that every part of your app sees consistent data. There's no question about whether one screen got stale data while another got fresh data—they all read from the same database.

Refresh from network — Fetch and upsert into the database

The repository owns the responsibility of keeping the database fresh. When you need to refresh data from an API, the repository fetches it via Retrofit, validates it, and then inserts or updates the database. The UI automatically reflects the changes because it's already observing the relevant Flow.

Let's add a Retrofit service and a refresh function:

kotlin
interface UserService {
    @GET("users/{id}")
    suspend fun fetchUser(@Path("id") userId: String): UserDto

    @GET("users")
    suspend fun fetchAllUsers(): List<UserDto>
}

class UserRepository(
    private val userDao: UserDao,
    private val userService: UserService
) {
    fun observeUser(userId: String): Flow<User?> {
        return userDao.observeUser(userId)
    }

    fun observeAllUsers(): Flow<List<User>> {
        return userDao.observeAllUsers()
    }

    suspend fun refreshUser(userId: String) {
        try {
            val dto = userService.fetchUser(userId)
            val user = dto.toDomain()
            userDao.upsertUser(user)
        } catch (e: Exception) {
            Log.e("UserRepository", "Failed to refresh user", e)
        }
    }

    suspend fun refreshAllUsers() {
        try {
            val dtos = userService.fetchAllUsers()
            val users = dtos.map { it.toDomain() }
            userDao.upsertUsers(users)
        } catch (e: Exception) {
            Log.e("UserRepository", "Failed to refresh users", e)
        }
    }
}

Your ViewModel triggers these refreshes on demand or on a schedule:

kotlin
class UserListViewModel(private val userRepository: UserRepository) : ViewModel() {
    val users = userRepository.observeAllUsers()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

    init {
        viewModelScope.launch {
            userRepository.refreshAllUsers()
        }
    }

    fun onRefreshRequested() {
        viewModelScope.launch {
            userRepository.refreshAllUsers()
        }
    }
}

Notice that the refresh function is suspend. The ViewModel launches it in viewModelScope, and by the time the network call completes and the database is updated, the Flow automatically emits the new data. The UI observing users sees the change without any additional coordination.

Hide data sources — ViewModels never see Retrofit or DAOs

A critical principle of the repository pattern is encapsulation. Your ViewModel, UI, and any other consumer should never import Retrofit, Room DAOs, or any other data layer class directly. Everything goes through the repository. This keeps concerns separated and makes your code easier to reason about.

Here's a bad pattern to avoid:

kotlin
// WRONG: ViewModel knows about DAO and Service
class BadViewModel(
    private val userDao: UserDao,
    private val userService: UserService
) : ViewModel() {
    val users = userDao.observeAllUsers().stateIn(...)

    fun refresh() {
        viewModelScope.launch {
            val dtos = userService.fetchAllUsers()
            val users = dtos.map { it.toDomain() }
            userDao.upsertUsers(users)
        }
    }
}

This couples the ViewModel to implementation details. If you switch from Room to Realm or from Retrofit to OkHttp, the ViewModel changes. If you want to add error handling, retry logic, or response transformation, the ViewModel becomes cluttered.

With the repository pattern, your ViewModel stays clean:

kotlin
// CORRECT: ViewModel only knows about repository
class UserListViewModel(private val userRepository: UserRepository) : ViewModel() {
    val users = userRepository.observeAllUsers()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

    fun onRefresh() {
        viewModelScope.launch {
            userRepository.refreshAllUsers()
        }
    }
}

The repository owns all the complexity: network retry logic, database transactions, data mapping, and error recovery. The ViewModel is lean and focused on business logic and state management.

Testable — Swap in fake repositories in tests

Because your ViewModel depends only on the repository interface, not on concrete implementations, testing becomes straightforward. You create a fake repository that returns predictable data, and your ViewModel tests run without touching the network or database.

kotlin
interface IUserRepository {
    fun observeUser(userId: String): Flow<User?>
    fun observeAllUsers(): Flow<List<User>>
    suspend fun refreshUser(userId: String)
    suspend fun refreshAllUsers()
}

class UserRepository(
    private val userDao: UserDao,
    private val userService: UserService
) : IUserRepository {
    // implementation...
}

class FakeUserRepository : IUserRepository {
    private val _users = MutableStateFlow<List<User>>(emptyList())

    override fun observeAllUsers(): Flow<List<User>> = _users.asStateFlow()

    override fun observeUser(userId: String): Flow<User?> {
        return _users.map { list -> list.find { it.id == userId } }
    }

    override suspend fun refreshAllUsers() {
        _users.value = listOf(
            User(id = "1", name = "Alice"),
            User(id = "2", name = "Bob")
        )
    }

    override suspend fun refreshUser(userId: String) {
        // stub
    }
}

Now your ViewModel tests are isolated and fast:

kotlin
@Test
fun testUserListLoadsOnInit() = runTest {
    val fakeRepo = FakeUserRepository()
    val viewModel = UserListViewModel(fakeRepo)

    advanceUntilIdle()

    val users = viewModel.users.value
    assertThat(users).hasSize(2)
    assertThat(users[0].name).isEqualTo("Alice")
}

You test the ViewModel in milliseconds without a database or HTTP server. You can test the real repository separately with integration tests or with pre-recorded API responses using a library like MockWebServer.

Common mistakes

Leaking implementation details: Exposing DTOs or entities from Retrofit or Room in the repository's public API forces consumers to know about these classes. Always map to domain models at the boundary.

Silent error handling: Catching exceptions in the repository and just logging them means errors never reach the UI. Consider emitting errors through a separate channel (a Flow, a LiveData, or a callback) so the ViewModel can inform the user.

Mixing refresh and initial load: Don't make observeData() trigger a network fetch. Keep observation and refresh separate. The observation returns cached data; the refresh is explicit. This prevents unexpected network calls and gives you fine-grained control over when data is refreshed.

Repository doing too much: A repository should manage data and coordinate between sources. Don't put business logic, validation, or calculations in the repository. That belongs in a use case, interactor, or the ViewModel.

Key takeaways

  • Expose Room queries as Flow so the UI automatically updates when data changes.
  • The repository is the single point where local and remote data meet; it decides which source is authoritative and when to refresh.
  • Hide Retrofit, Room, and other data layer details behind the repository interface so ViewModels and UI remain independent of implementation.
  • Program against repository interfaces, not concrete classes, to make testing trivial with fake implementations.
  • Keep refresh operations explicit and separate from observation to avoid unexpected network calls and improve predictability.
  • Map network and database models to domain models at the repository boundary to prevent leaks of infrastructure concerns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles