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
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
- MVVM core contract — Model (data/domain) — ViewModel (state holder, business logic) — View (UI observes state). ViewModel exposes StateFlow/LiveData. View collects and renders. Two-way data binding optional. Android Jetpack ViewModel survives config changes. Most widely used in Android today.
- MVVM strengths — Low boilerplate for simple screens. Jetpack-native (ViewModel, LiveData, DataBinding). Easy to onboard new developers. Google-recommended baseline. Works perfectly for 80% of screens — forms, lists, detail views. Tests focus on ViewModel state transformations.
- MVVM weaknesses — State can be scattered across multiple LiveData/StateFlow fields. ViewModel can bloat over time (God ViewModel). Events (one-shot side effects like navigation, toasts) need special handling — SharedFlow/Channel. No enforced unidirectional data flow — two-way binding can create hard-to-trace state bugs in large teams.
- MVI core contract — Model (immutable state) — View (renders state, emits Intents) — Intent (user action/event) — Reducer (Intent + current state = new state). Strictly unidirectional: View → Intent → ViewModel reduces → new State → View. State is a single sealed class/data class.
- MVI strengths — Single source of truth — one UI state object captures everything. Time-travel debugging and state logging are trivial. Predictable: given the same state + intent, output is always the same. Excellent for complex screens with many interactions. Natural fit with Jetpack Compose's recomposition model.
- MVI weaknesses — Higher boilerplate — every action needs an Intent class, every state needs a sealed class. Learning curve for teams new to functional thinking. Over-engineering for simple CRUD screens. State class can become large for complex screens unless broken into sub-states carefully.
- Clean Architecture layers — Presentation (ViewModels, UI) → Domain (UseCases, Repository interfaces, Entities) → Data (Repository implementations, Room DAOs, Retrofit services). Dependencies point inward only — Data and Presentation depend on Domain, never the reverse. Domain has zero Android dependencies.
- Clean Architecture strengths — Domain layer is pure Kotlin — unit testable without Android emulator. Swap data sources without touching domain or UI. Multiple delivery mechanisms (app + widget + tile) share the same domain. Enforces team boundaries in large codebases. Scales to 50+ screen apps.
- Clean Architecture weaknesses — Significant upfront investment — 3+ modules, interfaces everywhere, mappers between layers. For a 5-screen app it's overkill. UseCase-per-action can become verbose. Requires team discipline to maintain layer boundaries — easy to leak Android imports into domain accidentally.
- When to use what — interview answer — MVVM alone: small-medium apps, solo/small teams, tight deadlines, simple screens. MVVM + Clean: medium-large apps, team of 3+, need testability, clear feature boundaries. MVI + Clean: complex interactive screens (dashboards, editors), Compose-first projects, teams that value predictability over simplicity. Never be dogmatic — real-world is hybrid.
- How to answer 'why this architecture?' — Structure answer as: (1) Context — app size, team size, deadline. (2) Tradeoffs considered — what alternatives you evaluated. (3) Decision — what you chose and why. (4) What you'd change in retrospect. Shows engineering maturity. Interviewers want to hear 'it depends' backed by reasoning, not religious devotion.
- Architecture in Compose era — Jetpack Compose pushes toward MVI naturally — composables are pure functions of state, so single UiState data class + sealed Intent/Event fits perfectly. Google's Compose samples use UiState + Channel for events. MVVM with multiple StateFlows still works but state consistency becomes harder to guarantee.
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. MVVM ViewModel exposes a single _uiState MutableStateFlow backed by an immutable StateFlow — the UI collects from the public val only.
- 2. uiState.update { it.copy(...) } is the idiomatic way to update a data class state atomically — thread-safe and concise.
- 3. MVI sealed class OrderIntent models every possible user action as a type — exhaustive when in process() forces handling all cases.
- 4. OrderUiState is a single data class — the entire screen state is one object, making it easy to log, snapshot, and restore.
- 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. Clean Architecture: GetOrdersUseCase wraps the repository call — this is the Domain layer, zero Android imports, pure Kotlin.
- 7. OrderRepository is an interface in the Domain layer — Data layer provides the implementation, dependency inversion principle in action.
- 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. mapper::toDomain transforms Entity (database model) to Domain model — each layer has its own model class, decoupled from the others.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Guide to App Architecture — Android Developers (Android Developers)
- UI Layer — StateFlow & UiState patterns (Android Developers)
- MVI Architecture with Kotlin Flows (Medium)
- Android Architecture: MVVM vs MVI — Real Comparison (ProAndroidDev)
- Now in Android — Google's reference app with Clean + MVI (GitHub)