Repository Pattern, Use Cases & Dependency Inversion
Implement offline-first with Room + Retrofit behind a clean Repository, wire Use Cases into ViewModels with Hilt — the pattern in every production Android codebase
Open interactive version (quiz + challenge)Real-world analogy
What is it?
The Repository pattern provides a single source of truth for data by abstracting Retrofit (remote) and Room (local) behind a clean interface. Offline-first is achieved by always reading from Room and using the network only to refresh the cache. Use Cases encapsulate single business actions, operate on domain entities, and are injected into ViewModels via Hilt. Dependency Inversion ensures the Domain and Presentation layers depend on abstractions, with Hilt's @Binds wiring the concrete implementations at runtime.
Real-world relevance
An enterprise field ops app implements offline-first work order management: WorkOrderRepositoryImpl reads from Room (returns a Flow that emits on every DB change) and triggers Retrofit sync in the background. When the device is offline, engineers still see their work orders and can mark them complete — changes queue in Room. On reconnect, SyncWorkOrdersUseCase pushes pending changes to the API. The ViewModel is unaware of sync logic — it just calls GetPendingWorkOrdersUseCase.
Key points
- Repository as single source of truth — The Repository pattern centralizes all data access. The ViewModel asks 'give me orders' — the repository decides whether to fetch from network, return cached data, or combine both. The caller is shielded from data source complexity. This is the core offline-first pattern.
- Offline-first strategy — 1) Return local data immediately as a Flow. 2) Trigger a background network fetch. 3) Save to local DB. 4) Room Flow emits the updated data automatically. The UI always reads from Room — the network only feeds Room, never feeds UI directly. This ensures the app works without connectivity.
- NetworkBoundResource pattern — A formalized offline-first helper: emit loading state, emit cached data, fetch from network, save to cache, emit updated data. Can be implemented as a generic Flow> builder function or using the Paging 3 RemoteMediator for paginated data.
- Use Case design principles — Single Responsibility: one use case = one business action. Named as verbs: GetActiveOrdersUseCase, SubmitTimesheetUseCase, SyncWorkOrdersUseCase. Operate on domain entities only. May combine multiple repository calls. May apply business rules (filter, validate, transform). Must not know about UI or data source specifics.
- operator fun invoke() — Implementing operator fun invoke() in a use case lets you call it like a function: getActiveOrders(userId) instead of getActiveOrders.execute(userId). This is idiomatic Kotlin and the recommended pattern for use cases. It reads naturally at the call site in the ViewModel.
- Dependency Inversion with Hilt @Binds — @Binds in a Hilt @Module binds the Repository interface to its implementation. The ViewModel and UseCase only know the interface. The concrete class is an implementation detail wired by Hilt at compile time. Changing from a Retrofit impl to a GraphQL impl requires only a Hilt module change — no ViewModel or UseCase changes.
- Injecting into ViewModels — @HiltViewModel + @Inject constructor is the standard pattern. Use cases are injected via constructor — not repositories (except in rare cases where no use case layer exists). ViewModels should not have more than 3-5 use case dependencies — more suggests the ViewModel is doing too much and should be split.
- Flow vs suspend for repository methods — Use Flow for operations that produce multiple values over time (observing DB, real-time updates). Use suspend fun for one-shot operations (POST to API, insert to DB). Never use LiveData in the Domain or Data layer — it is an Android-lifecycle-aware wrapper that belongs in the Presentation layer at most.
- Error handling in the data layer — Wrap network calls in runCatching { } or use a sealed Result. Map exceptions to domain-specific error types (NetworkError, AuthError, NotFoundError). The ViewModel receives Result and maps it to UI state — it never catches raw IOException or HttpException. Error mapping happens at the Data layer boundary.
- Concrete example — Retrofit + Room + Use Case — WorkOrderRemoteDataSource wraps Retrofit API service. WorkOrderLocalDataSource wraps Room DAO. WorkOrderRepositoryImpl coordinates them. GetPendingWorkOrdersUseCase calls repository.getPendingOrders(). WorkOrderViewModel calls the use case. Each layer is independently testable: mock Retrofit for data layer, fake repo for use case, fake use case for ViewModel.
- Testing with fake repositories — Prefer FakeOrderRepository(fakeData) over mocking. Fakes implement the real Repository interface with in-memory lists. They are easier to set up, more readable in tests, and catch interface contract violations. Use MockK for Retrofit API service tests where you need to verify HTTP calls.
- The data layer in multi-module apps — Each feature's data module depends on the domain module for interfaces and entities. Data modules depend on :core:network (Retrofit client) and :core:database (Room DB). No cross-feature data dependencies — feature A's ViewModel never imports feature B's repository. Shared data goes through a :core:data module.
Code example
// ===== DOMAIN LAYER =====
data class WorkOrder(
val id: String, val title: String, val status: WorkOrderStatus,
val assignedTo: String?, val dueDate: LocalDate
)
enum class WorkOrderStatus { PENDING, IN_PROGRESS, COMPLETED, SYNCED }
interface WorkOrderRepository {
fun getPendingOrders(): Flow<List<WorkOrder>>
suspend fun completeOrder(orderId: String): Result<Unit>
suspend fun syncPendingOrders(): Result<Int> // returns synced count
}
class GetPendingWorkOrdersUseCase @Inject constructor(
private val repository: WorkOrderRepository
) {
operator fun invoke(): Flow<List<WorkOrder>> = repository.getPendingOrders()
.map { orders -> orders.sortedBy { it.dueDate } } // Business rule: sort by due date
}
class CompleteWorkOrderUseCase @Inject constructor(
private val repository: WorkOrderRepository
) {
suspend operator fun invoke(orderId: String): Result<Unit> {
require(orderId.isNotBlank()) { "Order ID cannot be blank" } // Business validation
return repository.completeOrder(orderId)
}
}
// ===== DATA LAYER =====
// Remote data source
class WorkOrderRemoteDataSource @Inject constructor(
private val api: WorkOrderApiService
) {
suspend fun fetchOrders(assigneeId: String): List<WorkOrderDto> =
api.getOrders(assigneeId)
suspend fun updateOrderStatus(orderId: String, status: String): WorkOrderDto =
api.updateOrder(orderId, UpdateOrderRequest(status))
}
// Local data source
class WorkOrderLocalDataSource @Inject constructor(
private val dao: WorkOrderDao
) {
fun observePendingOrders(): Flow<List<WorkOrderEntity>> =
dao.observeByStatus(WorkOrderStatus.PENDING.name, WorkOrderStatus.IN_PROGRESS.name)
suspend fun insertAll(orders: List<WorkOrderEntity>) = dao.insertAll(orders)
suspend fun updateStatus(orderId: String, status: String) = dao.updateStatus(orderId, status)
fun observeUnsynced(): Flow<List<WorkOrderEntity>> = dao.observeByStatus(WorkOrderStatus.COMPLETED.name)
}
// Repository implementation — offline-first
class WorkOrderRepositoryImpl @Inject constructor(
private val remote: WorkOrderRemoteDataSource,
private val local: WorkOrderLocalDataSource,
private val userSession: UserSession
) : WorkOrderRepository {
override fun getPendingOrders(): Flow<List<WorkOrder>> {
// Step 1: Return Room flow immediately (offline-first)
// Step 2: Trigger background refresh
return local.observePendingOrders()
.map { entities -> entities.map { it.toDomain() } }
.onStart { refreshFromNetwork() } // Trigger sync without blocking the flow
}
private suspend fun refreshFromNetwork() = runCatching {
val dtos = remote.fetchOrders(userSession.userId)
local.insertAll(dtos.map { it.toEntity() })
}
override suspend fun completeOrder(orderId: String): Result<Unit> = runCatching {
local.updateStatus(orderId, WorkOrderStatus.COMPLETED.name)
// Optimistic local update — sync will push to API
}
override suspend fun syncPendingOrders(): Result<Int> = runCatching {
var syncedCount = 0
local.observeUnsynced().first().forEach { entity ->
remote.updateOrderStatus(entity.id, entity.status)
local.updateStatus(entity.id, WorkOrderStatus.SYNCED.name)
syncedCount++
}
syncedCount
}
}
// ===== HILT DI =====
@Module @InstallIn(SingletonComponent::class)
abstract class WorkOrderModule {
@Binds @Singleton
abstract fun bindWorkOrderRepository(
impl: WorkOrderRepositoryImpl
): WorkOrderRepository
}
// ===== PRESENTATION LAYER =====
data class WorkOrderUiState(
val orders: ImmutableList<WorkOrder> = persistentListOf(),
val isLoading: Boolean = false,
val error: String? = null,
val syncMessage: String? = null
)
@HiltViewModel
class WorkOrderViewModel @Inject constructor(
private val getPendingOrders: GetPendingWorkOrdersUseCase,
private val completeOrder: CompleteWorkOrderUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(WorkOrderUiState())
val uiState: StateFlow<WorkOrderUiState> = _uiState.asStateFlow()
init {
loadOrders()
}
private fun loadOrders() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getPendingOrders()
.catch { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
.collect { orders ->
_uiState.update { it.copy(orders = orders.toImmutableList(), isLoading = false) }
}
}
}
fun onCompleteOrder(orderId: String) {
viewModelScope.launch {
completeOrder(orderId)
.onFailure { e -> _uiState.update { it.copy(error = e.message) } }
}
}
}
// ===== FAKE REPOSITORY for tests =====
class FakeWorkOrderRepository(
private val initialOrders: List<WorkOrder> = emptyList()
) : WorkOrderRepository {
private val _orders = MutableStateFlow(initialOrders)
override fun getPendingOrders(): Flow<List<WorkOrder>> = _orders
override suspend fun completeOrder(orderId: String): Result<Unit> {
_orders.update { orders ->
orders.map { if (it.id == orderId) it.copy(status = WorkOrderStatus.COMPLETED) else it }
}
return Result.success(Unit)
}
override suspend fun syncPendingOrders(): Result<Int> = Result.success(0)
}
// Unit test for use case
class CompleteWorkOrderUseCaseTest {
private val fakeRepo = FakeWorkOrderRepository(
initialOrders = listOf(WorkOrder("WO-1", "Fix leak", WorkOrderStatus.PENDING, null, LocalDate.now()))
)
private val useCase = CompleteWorkOrderUseCase(fakeRepo)
@Test
fun `completing order updates status`() = runTest {
val result = useCase("WO-1")
assertTrue(result.isSuccess)
assertEquals(WorkOrderStatus.COMPLETED, fakeRepo.getPendingOrders().first().first().status)
}
@Test(expected = IllegalArgumentException::class)
fun `blank order id throws`() = runTest { useCase("") }
}Line-by-line walkthrough
- 1. WorkOrderRepository interface in Domain declares getPendingOrders() as Flow> — returning domain entities, not DTOs or Room entities
- 2. GetPendingWorkOrdersUseCase adds a business rule on top of the repository: sort by dueDate — this is business logic, not UI logic, so it belongs in the use case
- 3. CompleteWorkOrderUseCase validates the orderId is not blank — business validation lives in the use case, not the ViewModel or repository
- 4. WorkOrderRepositoryImpl.getPendingOrders() returns local.observePendingOrders() immediately — the UI gets data without waiting for network
- 5. onStart { refreshFromNetwork() } triggers a background network sync when collection starts — the Flow emits local data first, then Room re-emits when the network data lands
- 6. completeOrder() does an optimistic local update only — marks COMPLETED in Room, leaves SYNCED for later. The offline engineer's completion persists immediately
- 7. syncPendingOrders() queries COMPLETED (unsynced) orders, pushes each to the API, then marks as SYNCED — this is called by a WorkManager job on network availability
- 8. @Binds in WorkOrderModule tells Hilt: WorkOrderRepository interface → inject WorkOrderRepositoryImpl. The ViewModel and use cases see only the interface
- 9. WorkOrderViewModel injects GetPendingWorkOrdersUseCase and CompleteWorkOrderUseCase — zero data layer imports. Swapping the backend requires no ViewModel changes
- 10. FakeWorkOrderRepository implements the real interface with a MutableStateFlow> — tests call completeOrder() and assert the Flow emits the updated list, just like the real app would
Spot the bug
// Data layer
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApiService,
private val dao: OrderDao
) : OrderRepository {
override fun getOrders(): Flow<List<Order>> = flow {
// Bug 1
val response = api.fetchOrders()
emit(response.map { it.toDomain() })
}
override suspend fun placeOrder(order: Order): Result<Unit> = runCatching {
// Bug 2
val response = api.placeOrder(order.toDto())
// Bug 3: no local save
}
}
// Domain layer
class GetOrdersUseCase @Inject constructor(private val repo: OrderRepository) {
// Bug 4
suspend fun getOrders(): List<Order> = repo.getOrders().first()
}
// ViewModel
@HiltViewModel
class OrderViewModel @Inject constructor(
private val getOrders: GetOrdersUseCase,
private val orderRepo: OrderRepositoryImpl // Bug 5
) : ViewModel() {
fun load() {
viewModelScope.launch {
val orders = getOrders.getOrders() // Bug 6
// use orders
}
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Data layer — Android Architecture Guide (Android Developers)
- Domain layer — Android Architecture Guide (Android Developers)
- Now in Android — Repository and UseCase patterns (Google GitHub)
- Repository Pattern in Android — Philipp Lackner (YouTube / PhilippLackner)
- Hilt dependency injection — @Binds and modules (Android Developers)