The UI layer has two halves, and merging them is why screens become untestable
Compose's recommended architecture is three layers with one-directional dependencies. The split inside the UI layer — elements and state holders — is the one that decides whether a screen can be tested without a device.

Day 16 of 100. Nine days of individual decisions — where state lives, what survives what, when to use an ambient. Today they resolve into one picture, and the picture has exactly one rule.
The symptom
A screen that can only be tested by launching it:
@Composable
fun OrderScreen(orderId: String) {
val repo = remember { OrderRepository(ApiClient(), OrderDatabase.get()) }
var order by remember { mutableStateOf<Order?>(null) }
LaunchedEffect(orderId) { order = repo.fetch(orderId) }
if (order == null) Spinner()
else Column {
Text(order!!.total.formatCurrency(Locale.getDefault()))
Button(onClick = { /* validate, then submit, then navigate */ }) { Text("Pay") }
}
}
Everything works. And testing "does the total format correctly for a German locale"
requires an emulator, a database, and a network stub, because the formatting logic is
sitting inside a composable next to a Button.
Why the obvious fix fails
The obvious fix is "add a ViewModel", which people do and then find nothing improved:
class OrderViewModel : ViewModel() {
private val repo = OrderRepository(ApiClient(), OrderDatabase.get()) // still constructed here
var order by mutableStateOf<Order?>(null) // still Compose state
fun onPayClick(context: Context) { … } // still needs Android
}
The code moved; the dependencies didn't. The ViewModel constructs its own repository, so
tests can't substitute one. It holds Compose state, so it needs the Compose runtime. It
takes a Context, so it needs a device.
Moving code between files isn't layering. Layering is about which direction the arrows point.

The actual mechanism
The recommended architecture is three layers, and the UI layer is two of them:
UI elements — composables that render state and emit events. No business logic, no dependencies beyond what's in their parameters. Previewable with literals.
State holders — ViewModels or plain classes that hold screen state and expose the operations on it. They know about the data layer, and nothing about Compose.
Data layer — repositories and data sources. They own the truth. They know nothing about screens.
The one rule: dependencies point one way — UI elements → state holders → data. No arrow ever points back up. A repository that imports a ViewModel, or a ViewModel that imports a composable, has broken the thing the layering exists to give you.
// Data layer — knows nothing above it
class OrderRepository(private val api: OrderApi, private val dao: OrderDao) {
suspend fun fetch(id: String): Order = dao.get(id) ?: api.fetch(id).also(dao::save)
}
// State holder — knows the data layer, not Compose
class OrderViewModel(
private val repo: OrderRepository, // injected, not constructed
private val formatter: CurrencyFormatter,
) : ViewModel() {
private val _state = MutableStateFlow(OrderUiState.Loading)
val state: StateFlow<OrderUiState> = _state.asStateFlow()
fun load(id: String) = viewModelScope.launch {
_state.value = OrderUiState.Ready(repo.fetch(id).toDisplay(formatter))
}
}
// UI element — knows only its parameters
@Composable
fun OrderContent(state: OrderUiState.Ready, onPay: () -> Unit) {
Column {
Text(state.formattedTotal) // already a String
Button(onClick = onPay) { Text("Pay") }
}
}
Note what left the composable: the formatting. state.formattedTotal is a String by
the time it reaches the UI, so the locale question is answered by a JVM unit test on the
state holder, in milliseconds, without a device.
That's the practical payoff of the split. Everything the UI layer does is "render this data"; everything else happened before.
The seam that makes it work
The screen composable is the only place the two halves of the UI layer meet:
@Composable
fun OrderScreen(orderId: String, vm: OrderViewModel = viewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(orderId) { vm.load(orderId) }
when (val s = state) {
OrderUiState.Loading -> Spinner()
is OrderUiState.Ready -> OrderContent(s, onPay = vm::pay)
is OrderUiState.Failed -> ErrorMessage(s.message, onRetry = { vm.load(orderId) })
}
}
Ten lines, no logic, one dependency. Everything below it is previewable; everything above it is unit-testable. This shape — a thin stateful screen wrapping stateless content — is the stateful/stateless pair from Day 11 applied at screen scale, and it's the single most useful convention in a Compose codebase.
What belongs where
The last nine days, sorted:
| Thing | Layer |
|---|---|
remember for UI element state (Day 12) |
UI elements |
| Hoisting to a parent (Day 11) | UI elements |
Screen state, StateFlow (Day 12) |
State holder |
LaunchedEffect for on-screen work (Day 9) |
Screen composable |
Data loading, mapLatest (Day 9) |
State holder / data |
rememberSaveable (Day 14) |
UI elements |
SavedStateHandle (Day 14) |
State holder |
| Theme, density (Day 15) | UI elements, via ambient |
| Formatting, validation, business rules | State holder or below |
The pattern in that table: anything a designer would recognise lives in the UI layer; anything a product manager would recognise lives below it. Imperfect, and it resolves most arguments quickly.
Not every screen needs three layers
Worth saying, because architecture posts tend to imply otherwise. A settings toggle that
writes one preference does not need a UiState hierarchy, a repository interface and a
mapper. The layering earns its cost when there is logic worth testing separately; below
that threshold it's ceremony, and ceremony has its own maintenance bill.
The signal to add a layer is a specific pain: "I can't test this without a device", "I can't preview this", "this changed and three unrelated files broke". Adding layers preemptively against pains you don't have is how a two-screen app acquires eleven modules.
The domain layer, and when it earns its place
There's an optional fourth box between the state holder and the data layer, and it's worth knowing the trigger rather than the definition. Use cases exist for logic that two or more state holders need, or logic complex enough to deserve tests of its own:
class CalculateOrderTotalUseCase(private val taxRules: TaxRules) {
operator fun invoke(items: List<Item>, region: Region): Money = …
}
A use case that a single ViewModel calls once, wrapping a single repository method, is a file that exists to satisfy a diagram. The genuine trigger is duplication or complexity — never symmetry. Most screens never need one, and a codebase where every screen has three is usually one where someone applied the picture rather than the rule.
How to prove it
The import check takes seconds and finds most violations:
grep -rl "androidx.compose" --include="*ViewModel.kt" src/
grep -rl "ViewModel\|Composable" --include="*Repository.kt" src/
Both should return nothing. A ViewModel importing Compose usually means
mutableStateOf where a StateFlow belongs — which works, and quietly requires the
Compose runtime in every test of that class.
The stronger check is a build-level one: put the data layer in its own Gradle module that doesn't depend on Compose at all. Then the arrows can't point the wrong way, because the wrong direction doesn't compile — a guarantee no amount of code review provides.
What this generalizes to
This is the dependency rule from Clean Architecture, in its smallest useful form: high-level policy shouldn't depend on low-level detail, and nothing should depend on the UI. MVP, MVVM, MVI and Redux all encode the same constraint with different vocabulary.
Compose sharpens one part of it — the split inside the UI layer between elements and state holders — because composables are cheap enough to make elements genuinely stateless, which older toolkits couldn't quite manage. Tomorrow, Day 17, is the piece that sits precisely on that seam: state holders that aren't ViewModels, and when a plain class is the better answer.
Day 16 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Compose and other libraries — architectural layering.