"Which architecture should I use?" is one of the most common questions from developers moving past tutorials into a real app. Here's what MVVM, MVI, and Clean Architecture actually look like in a Compose codebase, and a practical answer for when each is worth the overhead.
MVVM (Model-View-ViewModel)
MVVM is the default, lowest-friction pattern for Compose apps — and for good reason, since Android's own ViewModel and StateFlow APIs are built around it. The View (your composables) observes state exposed by the ViewModel; the ViewModel talks to a repository or data layer and exposes UI state, usually as a single StateFlow<UiState>.
class NotesViewModel(private val repo: NoteRepository) : ViewModel() {
private val _uiState = MutableStateFlow(NotesUiState())
val uiState: StateFlow<NotesUiState> = _uiState.asStateFlow()
fun loadNotes() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val notes = repo.getAll()
_uiState.update { it.copy(isLoading = false, notes = notes) }
}
}
}
Best for: most apps, especially small-to-medium teams and solo developers. It's what our own Notes App tutorial and Weather App tutorial use, and it's the pattern Google's own architecture guidance centers on.
MVI (Model-View-Intent)
MVI is a stricter variant: instead of the View calling arbitrary ViewModel functions, all user actions are modeled as explicit "Intent" (or "Event") objects sent through a single entry point, and the ViewModel reduces them into a new immutable UI state. This makes state transitions fully traceable and testable — every state change has exactly one path that produced it.
sealed interface NotesIntent {
data object LoadNotes : NotesIntent
data class DeleteNote(val id: Int) : NotesIntent
}
class NotesViewModel : ViewModel() {
private val _state = MutableStateFlow(NotesUiState())
val state: StateFlow<NotesUiState> = _state.asStateFlow()
fun onIntent(intent: NotesIntent) {
when (intent) {
is NotesIntent.LoadNotes -> loadNotes()
is NotesIntent.DeleteNote -> deleteNote(intent.id)
}
}
}
Best for: apps with complex, multi-step UI state (checkout flows, multi-screen wizards, undo/redo) where you want a single, auditable trail of "what happened and in what order." The extra ceremony (defining Intent types for every action) is real overhead that isn't worth it for a simple CRUD screen.
Clean Architecture (layered: data / domain / presentation)
Clean Architecture isn't a state-management pattern like the two above — it's a layering strategy, usually combined with MVVM or MVI at the presentation layer. The idea: separate your app into a data layer (Room, Retrofit, repositories), a domain layer (plain Kotlin use-case classes with no Android dependencies), and a presentation layer (ViewModels + Compose UI) — with dependencies only pointing inward, so the domain layer knows nothing about Android or the data layer's implementation details.
// domain layer — no Android or Retrofit/Room imports
class GetNotesUseCase(private val repository: NoteRepository) {
suspend operator fun invoke(): List<Note> = repository.getAll()
}
// presentation layer
class NotesViewModel(private val getNotes: GetNotesUseCase) : ViewModel() { /* ... */ }
Best for: larger teams and codebases expected to live for years, where the ability to swap a data source (e.g. Room for a different local database) without touching business logic, and to unit-test use cases with zero Android dependencies, pays for itself. For a small app or an MVP, the extra indirection (a use-case class per operation) is usually premature — you can always introduce a domain layer later once the app's complexity actually demands it.
A practical decision guide
- Solo project, MVP, or learning: MVVM with a single ViewModel per screen, talking directly to a repository. Don't add layers you don't need yet.
- Complex screen with many user actions and strict state transitions: MVI on top of MVVM's ViewModel/StateFlow foundation.
- Large team, long-lived codebase, multiple data sources: Clean Architecture's data/domain/presentation split, so the domain layer stays testable and portable.
These aren't mutually exclusive — a large app commonly uses Clean Architecture's layering for organization and MVI within the presentation layer for its most complex screens, while simpler screens stay plain MVVM. Pick per-screen complexity, not a single dogma for the whole app.