🏛️ Android Architecture and Clean Code

MVVM vs MVI: Which Android Architecture Should You Use?

MVVM and MVI both put a ViewModel between your UI and your data, and in Compose apps they look more alike than most blog posts admit. Here's exactly where they differ, the same screen in both styles, and how to choose.

✍️ Yunok Tech 📅 Updated ⏱ 6 min read 🎯 Intermediate
On this page
  1. What MVVM and MVI actually mean on Android
  2. One feature, two implementations
  3. The hard part: one-off events
  4. Testing: where MVI shines
  5. MVVM vs MVI at a glance
  6. When to use which: real scenarios
  7. Common mistakes with both patterns
  8. Frequently asked questions
  9. Conclusion

Ask five Android developers whether they use MVVM or MVI and you'll get five definitions. Some teams call any app with a ViewModel "MVVM". Others call any app with a sealed class Intent "MVI". The arguments are often about naming, not engineering.

This article cuts through that. It's for intermediate developers who already know ViewModels and StateFlow and want to decide how to structure screen logic. You'll see the precise difference between the two patterns, the same feature implemented both ways, how each handles one-off events such as navigation and snackbars, and how they affect testing.

What MVVM and MVI actually mean on Android

MVVM (Model–View–ViewModel)

The View (Activity, Fragment or composable) observes state exposed by a ViewModel, and calls ViewModel functions in response to user actions. The Model is your data and business layer (repositories, use cases). In modern Android MVVM, the ViewModel usually exposes a single StateFlow<UiState> and public functions like onSearchChange(query) or retry().

MVI (Model–View–Intent)

MVI makes the flow even stricter. Every user action is an Intent (an object, not a function call; unrelated to Android's Intent class). All intents go through one entry point. State changes happen in a reducer, a pure function (oldState, result) -> newState. The View renders one immutable state object.

The honest overlap

Google's recommended architecture, a ViewModel exposing immutable UI state through StateFlow with events flowing up from the UI, is already unidirectional data flow. MVVM done this way is very close to MVI. The real difference is how strictly you formalise intents and state transitions.

MVVM and MVI data flow compared Top: in MVVM, the View calls several ViewModel functions, each updating a StateFlow that the View observes. Bottom: in MVI, the View sends Intent objects to a single process function; results pass through a reducer that produces a new State, which the View renders, and side effects go out through a separate effect channel. MVVM View ViewModelonQueryChange() · retry()onItemClick() StateFlow function calls observe state MVI View Intent process()single entry reduce()pure function State Effect
MVVM uses several entry points that update state. MVI routes everything through one entry point and a pure reducer, and sends one-off effects (navigation, toasts) through a separate channel.

One feature, two implementations

The feature: a product search screen. The user types a query, results load after a short debounce, failures show an error with a retry button, and tapping a product navigates to its details. A shared repository interface keeps the comparison fair:

ProductRepository.ktKotlin
data class Product(val id: String, val name: String, val price: Double)

interface ProductRepository {
    suspend fun search(query: String): List<Product>   // throws IOException on network failure
}

MVVM implementation

SearchViewModel.kt (MVVM)Kotlin
data class SearchUiState(
    val query: String = "",
    val isLoading: Boolean = false,
    val results: List<Product> = emptyList(),
    val error: String? = null,
)

class SearchViewModel(private val repository: ProductRepository) : ViewModel() {

    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    // One-off navigation events: buffered so they aren't lost, consumed once.
    private val _navigation = Channel<String>(Channel.BUFFERED)
    val navigation: Flow<String> = _navigation.receiveAsFlow()

    private var searchJob: Job? = null

    fun onQueryChange(query: String) {
        _uiState.update { it.copy(query = query) }
        searchJob?.cancel()
        searchJob = viewModelScope.launch {
            delay(300)                 // debounce: only search after typing pauses
            search(query)
        }
    }

    fun retry() {
        viewModelScope.launch { search(_uiState.value.query) }
    }

    fun onProductClick(product: Product) {
        viewModelScope.launch { _navigation.send(product.id) }
    }

    private suspend fun search(query: String) {
        if (query.isBlank()) {
            _uiState.update { it.copy(results = emptyList(), error = null, isLoading = false) }
            return
        }
        _uiState.update { it.copy(isLoading = true, error = null) }
        try {
            val results = repository.search(query)
            _uiState.update { it.copy(isLoading = false, results = results) }
        } catch (e: IOException) {
            _uiState.update { it.copy(isLoading = false, error = "Couldn't load results. Check your connection.") }
        }
    }
}

This is clear and idiomatic. Each user action has a named function, state is immutable, and it is easy to read top to bottom. The weakness shows up as screens grow. State changes are scattered across many functions, so understanding every way isLoading can change means reading the whole class.

MVI implementation

SearchContract.kt (MVI)Kotlin
// Everything the user can do
sealed interface SearchIntent {
    data class QueryChanged(val query: String) : SearchIntent
    data object Retry : SearchIntent
    data class ProductClicked(val product: Product) : SearchIntent
}

// Everything that can happen as a result (internal to the ViewModel)
sealed interface SearchResult {
    data class QueryUpdated(val query: String) : SearchResult
    data object Loading : SearchResult
    data class Loaded(val products: List<Product>) : SearchResult
    data class Failed(val message: String) : SearchResult
    data object Cleared : SearchResult
}

// One-off effects the UI should perform exactly once
sealed interface SearchEffect {
    data class OpenProduct(val id: String) : SearchEffect
}

data class SearchState(
    val query: String = "",
    val isLoading: Boolean = false,
    val results: List<Product> = emptyList(),
    val error: String? = null,
)

// Pure: no coroutines, no repository, trivially unit-testable.
fun reduce(state: SearchState, result: SearchResult): SearchState = when (result) {
    is SearchResult.QueryUpdated -> state.copy(query = result.query)
    SearchResult.Loading -> state.copy(isLoading = true, error = null)
    is SearchResult.Loaded -> state.copy(isLoading = false, results = result.products)
    is SearchResult.Failed -> state.copy(isLoading = false, error = result.message)
    SearchResult.Cleared -> state.copy(isLoading = false, results = emptyList(), error = null)
}
SearchMviViewModel.kt (MVI)Kotlin
class SearchMviViewModel(private val repository: ProductRepository) : ViewModel() {

    private val _state = MutableStateFlow(SearchState())
    val state: StateFlow<SearchState> = _state.asStateFlow()

    private val _effects = Channel<SearchEffect>(Channel.BUFFERED)
    val effects: Flow<SearchEffect> = _effects.receiveAsFlow()

    private var searchJob: Job? = null

    // The single entry point for everything the UI does.
    fun process(intent: SearchIntent) {
        when (intent) {
            is SearchIntent.QueryChanged -> {
                emit(SearchResult.QueryUpdated(intent.query))
                searchJob?.cancel()
                searchJob = viewModelScope.launch {
                    delay(300)
                    search(intent.query)
                }
            }
            SearchIntent.Retry -> viewModelScope.launch { search(_state.value.query) }
            is SearchIntent.ProductClicked ->
                viewModelScope.launch { _effects.send(SearchEffect.OpenProduct(intent.product.id)) }
        }
    }

    private suspend fun search(query: String) {
        if (query.isBlank()) return emit(SearchResult.Cleared)
        emit(SearchResult.Loading)
        try {
            emit(SearchResult.Loaded(repository.search(query)))
        } catch (e: IOException) {
            emit(SearchResult.Failed("Couldn't load results. Check your connection."))
        }
    }

    private fun emit(result: SearchResult) = _state.update { reduce(it, result) }
}

It's more code. In return, every possible state transition is listed in one when expression, the reducer can be tested without coroutines, and you could log every intent and result to reconstruct exactly how a user reached a buggy state.

The Compose UI barely changes

SearchScreen.kt (MVI version)Kotlin
@Composable
fun SearchScreen(
    viewModel: SearchMviViewModel,
    onOpenProduct: (String) -> Unit,
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val lifecycleOwner = LocalLifecycleOwner.current

    // Collect one-off effects only while the screen is visible.
    LaunchedEffect(viewModel, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.effects.collect { effect ->
                when (effect) {
                    is SearchEffect.OpenProduct -> onOpenProduct(effect.id)
                }
            }
        }
    }

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        OutlinedTextField(
            value = state.query,
            onValueChange = { viewModel.process(SearchIntent.QueryChanged(it)) },
            label = { Text("Search products") },
            modifier = Modifier.fillMaxWidth(),
        )
        when {
            state.isLoading -> CircularProgressIndicator(Modifier.padding(24.dp))
            state.error != null -> Column(Modifier.padding(top = 16.dp)) {
                Text(state.error!!, color = MaterialTheme.colorScheme.error)
                TextButton(onClick = { viewModel.process(SearchIntent.Retry) }) { Text("Retry") }
            }
            else -> LazyColumn {
                items(state.results, key = { it.id }) { product ->
                    ListItem(
                        headlineContent = { Text(product.name) },
                        supportingContent = { Text("₹%.2f".format(product.price)) },
                        modifier = Modifier.clickable { viewModel.process(SearchIntent.ProductClicked(product)) },
                    )
                }
            }
        }
    }
}

The hard part: one-off events

Navigation, snackbars and toasts are the classic pain point in both patterns. If you store "navigate to product 42" in state, rotating the device may navigate again. If you use a hot SharedFlow without a buffer, events emitted while the UI isn't collecting are lost. Teams typically choose one of two approaches:

  • A buffered Channel exposed as a Flow, as in the examples above, collected with repeatOnLifecycle. Simple and widely used. In rare edge cases an event can still be dropped if it is delivered exactly as the collector is cancelled, so keep effects idempotent and non-critical.
  • Model the event as state and "consume" it. Store, for example, userMessage: String? in UI state and call onMessageShown() after displaying it. This is the approach Android's architecture guidance recommends, because state can't be lost.

Whichever you choose, choose it once for the whole codebase.

Testing: where MVI shines

SearchReducerTest.ktKotlin
class SearchReducerTest {
    @Test
    fun `failure stops loading and shows error`() {
        val loading = SearchState(query = "phone", isLoading = true)
        val result = reduce(loading, SearchResult.Failed("offline"))
        assertFalse(result.isLoading)
        assertEquals("offline", result.error)
        assertEquals("phone", result.query)   // unrelated fields untouched
    }
}

Reducer tests are plain JUnit: no dispatchers, no fakes, no waiting. MVVM ViewModels are also very testable with runTest and a fake repository, but you test transitions through the async code rather than in isolation.

MVVM vs MVI at a glance

AspectMVVM (with UDF)MVI
User actionsPublic functionsSealed Intent objects, one entry point
State updatesAnywhere in the ViewModelOnly via a reducer
BoilerplateLowMedium–high (Intent, Result, Effect, State)
TraceabilityGoodExcellent: every transition is enumerable and loggable
Learning curveGentleSteeper; teams need shared conventions
Best forMost screens, CRUD apps, small and medium teamsComplex, highly interactive screens; large teams wanting strict consistency
RiskState logic gets scattered as screens growCeremony on simple screens; over-engineering

When to use which: real scenarios

Settings or profile screen

MVVM. A handful of actions and simple state. MVI's Intent/Result layers would add files without adding clarity.

Checkout, chat or editor

MVI. Many interacting events (payment status, validation, retries, real-time updates) where an explicit reducer prevents impossible state combinations.

Mixed codebase, many teams

MVVM with UDF rules as the baseline, and MVI-style reducers allowed for complex screens. Consistency of state exposure matters more than the label.

Common mistakes with both patterns

  • Multiple mutable state streams (isLoading, results, error as separate flows). They can disagree with each other. Prefer one immutable state object per screen.
  • Exposing MutableStateFlow publicly. The UI could then mutate state, which breaks unidirectional flow.
  • Side effects inside the reducer, such as network calls, logging or navigation. Reducers must be pure.
  • Passing Context or Views into the ViewModel, which leaks Activities across configuration changes.
  • Choosing MVI for the rĂ©sumĂ© rather than for the screen's complexity.

Frequently asked questions

Is MVI better than MVVM?

Neither is universally better. MVI gives stricter, more traceable state transitions at the cost of boilerplate. MVVM with a single immutable state is simpler and sufficient for most screens.

Does Google recommend MVVM or MVI?

Google's architecture guide doesn't mandate either name. It recommends state holders (usually ViewModels) exposing immutable UI state with unidirectional data flow. Both patterns can follow that guidance.

Do I need a library for MVI?

No. As shown above, Kotlin sealed types, StateFlow and a reducer function are enough. Libraries can add conventions and tooling, but also dependencies to maintain.

Can I mix MVVM and MVI in one app?

Yes, as long as the team agrees where each is used. A common approach is MVVM by default, with reducers for complex screens.

Where do use cases fit?

Use cases belong to an optional domain layer below the ViewModel. They are independent of whether the ViewModel follows MVVM or MVI. See Clean Architecture in Android.

Conclusion

MVVM and MVI aren't rival philosophies. They are two levels of strictness on the same unidirectional data flow. Start with MVVM and a single immutable UI state. Move to MVI's intents and reducers when a screen's state transitions get hard to reason about, and agree on one approach to one-off events across the whole team.

Next step: take one of your existing ViewModels and list every way its state can change. If the list is short, MVVM is serving you well. If it's long and scattered, extract a reducer. Then read Clean Architecture in Android: Practical Lessons to structure the layers beneath the ViewModel.

Official references

📱 Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps →

Ready to build real Android applications?

Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp