MVVM Architecture in Compose
The last three lessons each introduced one piece of a bigger picture: a ViewModel that survives
configuration changes, events flowing up instead of direct mutation, and a StateFlow
exposed for the UI to collect. This lesson names the whole picture — MVVM
(Model-View-ViewModel) — and adds the one layer we've been skipping so far: where
repository.getAllRecipes() actually comes from.
The Three Layers
| Layer | Role | In RecipeBox |
|---|---|---|
| View | Composable functions — render state, send events up. No business logic, no data access. | RecipeListScreen, AddEditRecipeScreen |
| ViewModel | Holds UI state, handles events, talks to the Repository. Survives configuration changes. Knows nothing about Compose itself. | RecipeListViewModel, AddEditRecipeViewModel |
| Model (Repository + data sources) | The single source of truth for actual data — abstracts away where it comes from (database, network, memory). | RecipeRepository, backed by Room and/or a remote API |
Data and dependencies only ever flow in one direction through these layers: a Composable depends on
a ViewModel, and a ViewModel depends on a Repository — never the other way around. A Repository has
no idea a ViewModel exists, and a ViewModel has no idea Compose exists. That's what makes each layer
independently testable: you can unit-test RecipeListViewModel with a fake
RecipeRepository and zero Android or Compose dependencies at all.
The Model Layer: RecipeRepository
The Repository is defined as an interface first — this is what lets the ViewModel depend on "some source of recipes" without caring whether it's backed by Room, a REST API, or an in-memory list in a test:
interface RecipeRepository {
suspend fun getAllRecipes(): List<Recipe>
suspend fun getRecipeById(id: Int): Recipe?
suspend fun saveRecipe(recipe: Recipe)
suspend fun searchRecipes(query: String): List<Recipe>
}
A real implementation wraps a Room DAO (covered later in Room Database with Compose):
class RoomRecipeRepository(
private val recipeDao: RecipeDao
) : RecipeRepository {
override suspend fun getAllRecipes(): List<Recipe> = recipeDao.getAll()
override suspend fun getRecipeById(id: Int): Recipe? = recipeDao.getById(id)
override suspend fun saveRecipe(recipe: Recipe) = recipeDao.insert(recipe)
override suspend fun searchRecipes(query: String): List<Recipe> = recipeDao.search(query)
}
The ViewModel Layer: Constructor Injection
RecipeListViewModel depends on the RecipeRepository
interface, not the concrete RoomRecipeRepository class, and it receives
that dependency through its constructor rather than constructing it internally:
class RecipeListViewModel(
private val repository: RecipeRepository
) : ViewModel() {
private val _recipes = MutableStateFlow<List<Recipe>>(emptyList())
val recipes: StateFlow<List<Recipe>> = _recipes.asStateFlow()
init {
loadRecipes()
}
fun loadRecipes() {
viewModelScope.launch {
_recipes.value = repository.getAllRecipes()
}
}
}
Constructor injection is what makes this testable: a unit test can pass in a fake
RecipeRepository that returns a canned list instantly, with no database, no coroutine
dispatcher swapping, and no Android framework involved at all. Because viewModel()
expects a no-argument constructor by default, wiring a constructor-injected ViewModel like this one
up to a composable normally goes through a ViewModelProvider.Factory or a
dependency-injection framework like Hilt — the details of that wiring are outside this lesson's
scope, but the shape of the ViewModel itself doesn't change because of it.
The View Layer: Just Rendering State
With the layers below it in place, the composable is exactly as thin as it should be — read state, render it, forward events:
@Composable
fun RecipeListScreen(
viewModel: RecipeListViewModel,
onRecipeClick: (Int) -> Unit
) {
val recipes by viewModel.recipes.collectAsStateWithLifecycle()
LazyColumn {
items(recipes, key = { it.id }) { recipe ->
RecipeRow(recipe = recipe, onClick = { onRecipeClick(recipe.id) })
}
}
}
Nothing in RecipeListScreen mentions Room, a DAO, or even the word "repository" — it
depends only on the ViewModel's public surface: one StateFlow to observe. That's the
payoff of the whole chain: the View can be redesigned completely, the data source can be swapped
from Room to a network API, and as long as the ViewModel's exposed StateFlow<List<Recipe>>
contract stays the same, neither layer needs to know the other one changed.