Three layers and one source of truth
Think of a restaurant. Waiters (the UI layer) talk to guests and show them what’s available. The kitchen’s recipes (the domain layer) decide how dishes are made, the same way whichever waiter takes the order. The pantry and suppliers (the data layer) make sure ingredients are there. Waiters never go to the supplier themselves.
In an Android app:
- UI layer: composables and ViewModels. The ViewModel turns data into UI state and handles user actions.
- Domain layer (optional): use cases, small classes holding a business rule several screens need, like “can this order be cancelled?”.
- Data layer: repositories, which decide where data comes from, and data sources below them: a Retrofit API, a Room database, DataStore.
Each layer only uses the one below it. A composable never calls Retrofit; a repository never knows about screens.
Google’s guide to app architecture defines a UI layer (UI elements plus state holders exposing immutable UI state through UDF), an optional domain layer (use cases encapsulating reusable or complex business logic, typically one public operator fun invoke), and a data layer (repositories as the single entry point per data type, coordinating data sources and exposing Flows, with the local database as the source of truth for offline-capable data). That’s “Clean Architecture” in its pragmatic Android form: dependencies point inward toward data and domain, and the domain is plain Kotlin, but there’s usually no separate domain model for every entity and no mandatory use case for every call. The two disciplines that matter most: the UI never touches data sources directly, and every piece of data has one source of truth that every screen observes.
// Data source: Room
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE customerId = :customerId ORDER BY createdAt DESC")
fun observeOrders(customerId: String): Flow<List<OrderEntity>>
@Upsert suspend fun upsertAll(orders: List<OrderEntity>)
}
// Repository: the only class the rest of the app asks for orders
class OrderRepository @Inject constructor(
private val dao: OrderDao,
private val api: OrdersApi,
@IoDispatcher private val io: CoroutineDispatcher,
) {
fun orders(customerId: String): Flow<List<Order>> =
dao.observeOrders(customerId).map { rows -> rows.map(OrderEntity::toDomain) }
suspend fun refresh(customerId: String) = withContext(io) {
val fresh = api.orders(customerId) // network DTOs
dao.upsertAll(fresh.map(OrderDto::toEntity)) // the Flow above emits automatically
}
}
// Domain layer: a rule two screens need
class CanCancelOrderUseCase @Inject constructor(private val clock: Clock) {
operator fun invoke(order: Order): Boolean =
order.status == OrderStatus.PLACED &&
Duration.between(order.createdAt, clock.instant()) < Duration.ofHours(1)
}
// UI layer: the ViewModel combines them into UI state
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val repository: OrderRepository,
private val canCancel: CanCancelOrderUseCase,
session: Session,
) : ViewModel() {
private val customerId = session.customerId
val uiState: StateFlow<OrdersUiState> = repository.orders(customerId)
.map { orders -> OrdersUiState.Loaded(orders.map { OrderRow(it, canCancel = canCancel(it)) }) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState.Loading)
init { viewModelScope.launch { runCatching { repository.refresh(customerId) } } }
}- The screen observes the database, through the repository. Whatever is in Room is shown immediately, even offline.
refresh()downloads fresh orders and writes them to Room. Because the screen observes Room, it updates by itself; nobody has to “tell” it.- Any other screen that changes an order (cancelling it on the detail screen) writes to Room too, so the list updates as well.
CanCancelOrderUseCaseholds the cancel rule once. The list and the detail screen both use it, and it can be tested with a fake clock in a few lines.
Tap the line with the bug.
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val api: OrdersApi,
session: Session,
) : ViewModel() {
private val _uiState = MutableStateFlow<OrdersUiState>(OrdersUiState.Loading)
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch {
_uiState.value = try {
OrdersUiState.Loaded(api.orders(session.customerId).map { it.toRow() })
} catch (e: IOException) {
OrdersUiState.Error
}
}
}
}Hint: What does the screen show without a network? And after the user cancels an order on the detail screen and comes back?
The ViewModel talks to the network directly, and its state is a one-time snapshot of one request:
- Offline, the request fails and the screen shows an error, although the app downloaded these orders an hour ago.
- After a change elsewhere (an order cancelled on the detail screen, or a push saying it shipped), this screen still shows the old snapshot until something reloads it.
- Tests need a fake
OrdersApifor every screen that shows orders, and each screen maps DTOs on its own.
Move data access into the repository, observe the database, and refresh it from the network:
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val repository: OrderRepository,
session: Session,
) : ViewModel() {
val uiState: StateFlow<OrdersUiState> = repository.orders(session.customerId)
.map { OrdersUiState.Loaded(it.map(Order::toRow)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState.Loading)
init {
viewModelScope.launch {
runCatching { repository.refresh(session.customerId) } // offline: keep showing cached data
}
}
}A failed refresh can still be shown (a small “Couldn’t update” banner), as an extra field in the state, without throwing away the data the user can already see.
Predict the output
With the fixed design, the detail screen cancels order 1042 by calling repository.cancel(1042), which calls the API and then updates the row in Room. The orders list is still in the back stack. What does the list show when the user goes back?
The list observes dao.observeOrders(...) through the repository. Updating the row in Room makes that Flow emit a new list, and the ViewModel maps it into new UI state. With WhileSubscribed(5_000) the subscription may have paused while the list was off screen, but it restarts on return and reads the current database. Either way, the user sees Cancelled without any screen telling another screen anything.
- UI layer (composables + ViewModels, immutable UI state, UDF), optional domain layer (use cases), data layer (repositories over data sources).
- Dependencies point toward data/domain; UI never touches Retrofit, Room or DataStore directly.
- Local database as source of truth: repositories expose
Flows from Room andrefresh()writes network data into it; all screens stay consistent. - Use cases for rules that are reused or complex, with injected clocks and dispatchers for testability.
- Refresh failures are extra state, not a replacement for cached data.
Check this part
Which layer is allowed to call the Retrofit API?
Why does the list update after an order is cancelled on another screen?
When is a use case worth adding?