Lesson 25 of 83 advanced

MVVM vs MVI vs Clean — Tradeoffs & Interview Answers

Compare architectures, show real tradeoffs, and answer 'why this architecture?' without dogma

Open interactive version (quiz + challenge)

Real-world analogy

Choosing an architecture is like choosing a kitchen layout. MVVM is the classic home kitchen — familiar, flexible, works great for most meals. MVI is a professional restaurant kitchen — strict stations, one-way ticket system, zero ambiguity, but more setup. Clean Architecture is the full restaurant operation — front-of-house, back-of-house, supply chain all separated, ultimate scale but you need the team to justify it.

What is it?

MVVM, MVI, and Clean Architecture are patterns for separating concerns in Android apps. MVVM is the Jetpack-native baseline — low ceremony, widely understood. MVI adds strict unidirectionality and a single immutable state object — predictable but verbose. Clean Architecture adds layer separation (Presentation/Domain/Data) for testability and scalability. The winning interview answer shows you understand each pattern's tradeoffs and can match the right tool to the context.

Real-world relevance

At Tixio (SaaS real-time collaboration), the team adopted MVI + Clean for the workspace board screen because 15+ user interactions (drag, resize, invite, comment, sync) all mutate the same canvas state — MVVM's multiple StateFlows caused consistency bugs. For the simpler settings and profile screens, plain MVVM was kept. At BRAC's field operations app, Clean Architecture allowed swapping the sync backend from Firebase to a custom REST API without touching domain UseCases or any ViewModel.

Key points

Code example

// MVVM — standard ViewModel approach
class OrderListViewModel(
    private val getOrders: GetOrdersUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow(OrderListState())
    val uiState: StateFlow<OrderListState> = _uiState.asStateFlow()

    fun loadOrders() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            getOrders().collect { orders ->
                _uiState.update { it.copy(orders = orders, isLoading = false) }
            }
        }
    }
}

// MVI — Intent + Reducer pattern
sealed class OrderIntent {
    object LoadOrders : OrderIntent()
    data class DeleteOrder(val id: String) : OrderIntent()
    data class FilterByStatus(val status: OrderStatus) : OrderIntent()
}

data class OrderUiState(
    val orders: List<Order> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
    val filter: OrderStatus? = null
)

class OrderMviViewModel(
    private val getOrders: GetOrdersUseCase,
    private val deleteOrder: DeleteOrderUseCase
) : ViewModel() {
    private val _state = MutableStateFlow(OrderUiState())
    val state: StateFlow<OrderUiState> = _state.asStateFlow()

    fun process(intent: OrderIntent) {
        when (intent) {
            is OrderIntent.LoadOrders -> loadOrders()
            is OrderIntent.DeleteOrder -> delete(intent.id)
            is OrderIntent.FilterByStatus -> filter(intent.status)
        }
    }

    private fun loadOrders() { /* ... */ }
    private fun delete(id: String) { /* ... */ }
    private fun filter(status: OrderStatus) {
        _state.update { it.copy(filter = status) }
    }
}

// Clean Architecture — Domain layer (zero Android imports)
// Domain/UseCase
class GetOrdersUseCase(private val repo: OrderRepository) {
    operator fun invoke(): Flow<List<Order>> = repo.getOrders()
}

// Domain/Repository interface
interface OrderRepository {
    fun getOrders(): Flow<List<Order>>
    suspend fun syncOrders()
}

// Data/Repository implementation
class OrderRepositoryImpl(
    private val dao: OrderDao,
    private val api: OrderApiService,
    private val mapper: OrderMapper
) : OrderRepository {
    override fun getOrders(): Flow<List<Order>> =
        dao.observeAll().map { entities -> entities.map(mapper::toDomain) }

    override suspend fun syncOrders() {
        val remote = api.fetchOrders()
        dao.upsertAll(remote.map(mapper::toEntity))
    }
}

Line-by-line walkthrough

  1. 1. MVVM ViewModel exposes a single _uiState MutableStateFlow backed by an immutable StateFlow — the UI collects from the public val only.
  2. 2. uiState.update { it.copy(...) } is the idiomatic way to update a data class state atomically — thread-safe and concise.
  3. 3. MVI sealed class OrderIntent models every possible user action as a type — exhaustive when in process() forces handling all cases.
  4. 4. OrderUiState is a single data class — the entire screen state is one object, making it easy to log, snapshot, and restore.
  5. 5. process(intent) is the single entry point for the MVI ViewModel — the View calls this for every user action, no other public functions needed.
  6. 6. Clean Architecture: GetOrdersUseCase wraps the repository call — this is the Domain layer, zero Android imports, pure Kotlin.
  7. 7. OrderRepository is an interface in the Domain layer — Data layer provides the implementation, dependency inversion principle in action.
  8. 8. OrderRepositoryImpl bridges Domain and Data — it reads from Room DAO (Flow for reactivity) and writes to Room on sync, never exposing Data models to Domain.
  9. 9. mapper::toDomain transforms Entity (database model) to Domain model — each layer has its own model class, decoupled from the others.
  10. 10. The repository exposes Flow> so any collector (ViewModel, UseCase) gets real-time updates when Room data changes.

Spot the bug

// Spot all architecture violations — there are 5
class DashboardViewModel(
    private val orderDao: OrderDao,              // Bug 1
    private val api: RetrofitApiService,         // Bug 2
    private val prefs: SharedPreferences         // Bug 3
) : ViewModel() {
    private val _orders = MutableStateFlow<List<OrderEntity>>(emptyList())  // Bug 4
    val orders = _orders.asStateFlow()

    private val _isLoading = MutableStateFlow(false)
    private val _error = MutableStateFlow<String?>(null)

    fun loadDashboard() {
        viewModelScope.launch {
            _isLoading.value = true
            try {
                val orders = api.getOrders().body()!!  // Bug 5
                _orders.value = orders.map { OrderEntity(it.id, it.title, it.status) }
                prefs.edit().putLong("last_sync", System.currentTimeMillis()).apply()
            } catch (e: Exception) {
                _error.value = e.message
            } finally {
                _isLoading.value = false
            }
        }
    }
}
Need a hint?
Look at what the ViewModel imports, what it exposes, how many state flows it has, how it accesses data, and how it handles the API response.
Show answer
Bug 1: ViewModel directly imports OrderDao (Data layer) — violates Clean Architecture. Presentation must not depend on Data. Fix: inject a UseCase that wraps the repository. Bug 2: ViewModel directly imports RetrofitApiService (Data layer) — same violation. The ViewModel should never know about Retrofit. Fix: GetOrdersUseCase handles all data orchestration. Bug 3: SharedPreferences access in ViewModel — infrastructure concern leaked into Presentation. Fix: create a SyncRepository or SyncUseCase that handles the last_sync timestamp internally. Bug 4: ViewModel exposes List<OrderEntity> — Data layer model exposed to Presentation, violating layer boundaries. Fix: map to a Domain model (Order) or a UI model (OrderUiItem) before emitting. Bug 5: .body()!! — force unwraps a nullable Response body, will crash if the API returns a non-2xx response or empty body. Fix: use a safe wrapper like response.body() ?: emptyList() or use a Result-based API wrapper that maps errors to domain exceptions before they reach the ViewModel.

Explain like I'm 5

Imagine your bedroom. MVVM is keeping your stuff in labeled boxes — toys here, books there. Easy to find things. MVI is having ONE master list of everything in your room at all times — you never lose track but writing the list takes longer. Clean Architecture is like separating your room, your house, and your neighbourhood — each has its own rules and doesn't mess with the others. All three keep things organised, just at different scales.

Fun fact

Google's own Android architecture samples on GitHub have changed recommended patterns four times since 2017 — from MVP to MVVM to MVVM+LiveData to MVVM+StateFlow to now MVI-leaning with Compose. The pattern war is real, which is why interviewers ask about tradeoffs rather than 'which is correct'.

Hands-on challenge

Take a ViewModel you've written with 3+ separate StateFlow properties representing parts of the same screen's state. Refactor it to a single UiState data class. Then identify all one-shot events (navigation, Snackbars) and move them to a SharedFlow. Write the before/after and explain what problems the refactor solves.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery