Lesson 24 of 83 advanced

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

The Repository is like a smart librarian who knows exactly where to get any book — they check the local shelves first (Room cache), and if the book isn't there or is outdated, they order it from the central warehouse (Retrofit/API). The Use Case is like a specific request slip — 'Get me all books on Kotlin published after 2023.' The ViewModel is the student who fills out the slip and waits for the result. The student never talks to the librarian directly — the slip (Use Case) is the contract.

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

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. 1. WorkOrderRepository interface in Domain declares getPendingOrders() as Flow> — returning domain entities, not DTOs or Room entities
  2. 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. 3. CompleteWorkOrderUseCase validates the orderId is not blank — business validation lives in the use case, not the ViewModel or repository
  4. 4. WorkOrderRepositoryImpl.getPendingOrders() returns local.observePendingOrders() immediately — the UI gets data without waiting for network
  5. 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. 6. completeOrder() does an optimistic local update only — marks COMPLETED in Room, leaves SYNCED for later. The offline engineer's completion persists immediately
  7. 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. 8. @Binds in WorkOrderModule tells Hilt: WorkOrderRepository interface → inject WorkOrderRepositoryImpl. The ViewModel and use cases see only the interface
  9. 9. WorkOrderViewModel injects GetPendingWorkOrdersUseCase and CompleteWorkOrderUseCase — zero data layer imports. Swapping the backend requires no ViewModel changes
  10. 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?
Find the offline-first violation, missing local persistence, wrong return type from use case, concrete class injection, and non-idiomatic use case invocation.
Show answer
Bug 1: getOrders() fetches directly from the API and emits — this is NOT offline-first. If the device is offline, the flow fails immediately. Fix: emit from dao.observeOrders() (Room Flow), and trigger an API refresh separately with onStart { refreshFromApi() } that saves to Room. Room's Flow handles re-emitting. Bug 2: placeOrder maps Order to DTO and calls the API but never saves the result to Room. For offline-first, fix: save to Room first (optimistic update), then sync with API in background, handle API failure by keeping the local record for later sync. Bug 3: Follows from Bug 2 — there is no dao.insert() call. Room never receives the new order, so the orders Flow never emits it — the UI will not show the newly placed order. Bug 4: GetOrdersUseCase returns suspend fun List<Order> by calling .first() on the Flow — this kills the reactive updates. The ViewModel will load orders once and never see updates. Fix: return Flow<List<Order>> directly: operator fun invoke(): Flow<List<Order>> = repo.getOrders(). Bug 5: ViewModel injects OrderRepositoryImpl (concrete class) alongside the use case — this violates Clean Architecture (Presentation importing Data) and bypasses the use case layer. Fix: remove orderRepo injection; if additional operations are needed, create more use cases. Bug 6: getOrders.getOrders() is non-idiomatic — with operator fun invoke(), the call site should be getOrders() not getOrders.getOrders(). Also, since getOrders should return a Flow (Bug 4 fix), the ViewModel should collect it: getOrders().collect { orders -> ... }.

Explain like I'm 5

The Repository is like a school cafeteria that always has food ready (Room cache) and places new orders in the background (Retrofit). Students (ViewModels) never go to the kitchen directly — they order from the cafeteria counter (Use Case). If the kitchen gets a delivery, the cafeteria automatically updates the trays — students get fresh food without asking again (Room Flow emitting on DB change). Dependency Inversion is like the school not caring which catering company supplies the kitchen — they just need food delivered. The school (Domain) sets the menu (interface), and whoever wins the catering contract (Retrofit or Firebase) delivers to spec.

Fun fact

The Repository pattern predates Android — it was documented by Martin Fowler in his 2003 book 'Patterns of Enterprise Application Architecture' as a way to abstract database access in Java EE applications. Google's Architecture Guide adopted and adapted it for Android around 2017, and it is now so standard that it is included in the official Now in Android sample app (NiA) which has over 14,000 GitHub stars and is Google's recommended reference architecture.

Hands-on challenge

Implement offline-first SyncTimesheet feature: TimesheetRepository with getMyTimesheets(): Flow> (Room-backed), submitTimesheet(entry: TimesheetEntry): Result (optimistic local, background sync), syncPending(): Result. Create SubmitTimesheetUseCase with business validation (no future dates, no duplicates). Wire with Hilt @Binds. Write FakeTimesheetRepository. Write unit tests for SubmitTimesheetUseCase covering success, duplicate entry, and network error cases.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery