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

LayerRoleIn RecipeBox
ViewComposable functions — render state, send events up. No business logic, no data access.RecipeListScreen, AddEditRecipeScreen
ViewModelHolds 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.

Note: This is correct, real Compose/Kotlin code — but this website's own project doesn't run a live Android Studio build, so you won't see it actually render on this page.
Next up: derivedStateOf & rememberSaveable covers two more targeted state tools — computing values efficiently from other state, and surviving process death, not just recomposition.