The journey of Android development has evolved dramatically over the past decade. What once seemed like sufficient architecture—throwing everything into Activities and Fragments—has become a recipe for unmaintainable chaos. Today, as we build increasingly complex applications, the need for proper architectural patterns has never been more critical. This is where MVVM (Model-View-ViewModel) becomes indispensable.
MVVM represents a fundamental shift in how we structure Android applications. Rather than allowing our UI layers to become tangled with business logic, MVVM enforces a clean separation of concerns. When combined with Kotlin and Jetpack Compose, MVVM transforms from a theoretical pattern into a practical, developer-friendly approach that makes your code more testable, maintainable, and scalable.
In this comprehensive guide, we'll explore MVVM architecture in depth—not just the theory, but how to implement it effectively using modern Android development tools. By the end, you'll understand not just how to build structured Android apps, but why this structure matters for your project's long-term success.
Understanding the MVVM Pattern: The Three Pillars
MVVM stands for Model-View-ViewModel, and each component has a specific, well-defined responsibility. Before we write any code, let's establish a shared mental model of what each layer does and why this separation matters.
The Model: Your Data and Business Logic
The Model represents your application's data and business logic—the core of your app that exists independent of how users see it. This includes your database entities, API responses, repositories, and use cases. The Model knows nothing about the UI; it doesn't care whether data will be displayed in Compose, XML layouts, or even a CLI interface.
Think of the Model as the engine of your car. It runs regardless of whether someone is inside the car (UI) or not. If you swap out the transmission (change your UI framework), the engine still works.
// Data class representing our core data
data class Task(
val id: Int,
val title: String,
val description: String,
val isCompleted: Boolean,
val createdAt: LocalDateTime
)
// Repository abstracting data access
interface TaskRepository {
fun getAllTasks(): Flow<List<Task>>
suspend fun addTask(task: Task)
suspend fun updateTask(task: Task)
suspend fun deleteTask(taskId: Int)
}
The View: Your UI Layer
The View in MVVM is your User Interface—everything the user sees and interacts with. In modern Android development with Jetpack Compose, your Views are composable functions that describe what should be displayed based on the current state.
The critical principle here is that Views are passive. They don't decide what to display based on their own logic; instead, they observe state provided by the ViewModel and react to changes. When a user taps a button, the View doesn't handle the business logic—it merely notifies the ViewModel that an action occurred.
@Composable
fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel()
) {
val tasks by viewModel.tasks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
// UI reacts to state changes
if (isLoading) {
LoadingIndicator()
} else {
LazyColumn {
items(tasks) { task ->
TaskItem(task = task, onDelete = { viewModel.deleteTask(task.id) })
}
}
}
}
The ViewModel: The Bridge and Logic Orchestrator
The ViewModel is the bridge between your Model and View. It holds UI-related state, exposes data to the View layer, and handles user interactions by executing appropriate business logic from the Model layer. Crucially, ViewModels survive configuration changes (like screen rotation), preserving state across these transitions.
The ViewModel's responsibilities include:
- Holding and managing UI state
- Exposing data to the UI through reactive streams (StateFlow)
- Handling user interactions and translating them into business operations
- Managing coroutines for asynchronous operations
- Maintaining the app's logical state independent of UI lifecycle events
StateFlow: The Foundation of Reactive State Management
At the heart of MVVM with Jetpack Compose lies StateFlow—a modern reactive data stream from Kotlin's coroutines library. Understanding StateFlow is essential because it's what allows your UI to react automatically to state changes without manual observer management.
Why StateFlow Over LiveData?
While LiveData was the go-to for many years, StateFlow offers several advantages that make it the preferred choice for Compose applications:
1. Coroutine-Native Design: StateFlow is built from the ground up with coroutines in mind. It integrates seamlessly with Kotlin's suspend functions and coroutine scopes, whereas LiveData requires additional abstraction layers.
// LiveData approach (older pattern)
class OldViewModel : ViewModel() {
private val _count = MutableLiveData(0)
val count: LiveData<Int> = _count
}
// StateFlow approach (modern pattern)
class ModernViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
}
2. Flow Operators for Transformation: StateFlow gives you access to Kotlin's rich Flow operators (map, filter, flatMapLatest, etc.), enabling sophisticated data transformations with minimal boilerplate.
3. Lifecycle-Aware Collection: With Jetpack Compose, collecting StateFlow is seamless using collectAsState(), which is lifecycle-aware out of the box.
// Elegantly collect StateFlow in Compose
val taskCount by viewModel.taskCount.collectAsState()
4. Hot Stream Semantics: StateFlow always emits the latest value to new collectors, and it only runs when it has collectors—making it more memory-efficient than LiveData in certain scenarios.
Implementing StateFlow in Your ViewModel
Here's the pattern we recommend for StateFlow management:
class TaskViewModel(
private val taskRepository: TaskRepository
) : ViewModel() {
// Private mutable version - only ViewModel can modify
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
// Public read-only version - UI observes this
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
// Loading state
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
loadTasks()
}
private fun loadTasks() {
viewModelScope.launch {
_isLoading.value = true
try {
taskRepository.getAllTasks().collect { taskList ->
_tasks.value = taskList
_error.value = null
}
} catch (e: Exception) {
_error.value = e.message ?: "Unknown error occurred"
} finally {
_isLoading.value = false
}
}
}
fun addTask(task: Task) {
viewModelScope.launch {
try {
taskRepository.addTask(task)
} catch (e: Exception) {
_error.value = "Failed to add task: ${e.message}"
}
}
}
}
Notice the encapsulation pattern: we expose read-only StateFlow while keeping mutable versions private. This prevents the UI layer from accidentally modifying state in ways the ViewModel doesn't control.
Jetpack Compose Integration: Reactive UIs Made Simple
Jetpack Compose fundamentally changed how we think about UI development. Its declarative nature aligns perfectly with MVVM and reactive state management. Instead of imperative commands ("set this text to that value"), you describe what the UI should look like given a particular state.
Collecting State in Composables
The bridge between ViewModel state and Compose is the collectAsState() function. This function collects values from a StateFlow and converts them into Compose State, which triggers recomposition when values change.
@Composable
fun TaskListScreen(
viewModel: TaskViewModel = hiltViewModel()
) {
// Collect StateFlow as Compose State
val tasks by viewModel.tasks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
when {
isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
error != null -> {
ErrorMessage(
message = error ?: "Unknown error",
onRetry = { viewModel.loadTasks() },
modifier = Modifier.align(Alignment.Center)
)
}
tasks.isEmpty() -> {
EmptyState(
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(
items = tasks,
key = { it.id }
) { task ->
TaskItemRow(
task = task,
onComplete = { viewModel.completeTask(task.id) },
onDelete = { viewModel.deleteTask(task.id) },
onEdit = { viewModel.setEditingTask(task) }
)
}
}
}
}
}
}
Event Handling: User Interactions
When users interact with your UI—tapping buttons, entering text, submitting forms—these interactions must flow back to the ViewModel. The pattern is straightforward: composables call ViewModel methods in response to user actions.
@Composable
fun TaskInputField(viewModel: TaskViewModel) {
var taskTitle by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = taskTitle,
onValueChange = { taskTitle = it },
label = { Text("Task Title") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
if (taskTitle.isNotBlank()) {
viewModel.addTask(Task(
id = 0, // Repository will assign
title = taskTitle,
description = "",
isCompleted = false,
createdAt = LocalDateTime.now()
))
taskTitle = "" // Clear input
}
},
modifier = Modifier
.align(Alignment.End)
.padding(top = 8.dp)
) {
Text("Add Task")
}
}
}
This unidirectional data flow—state flows down, events flow up—is the cornerstone of MVVM with Compose. It makes the relationship between UI and logic explicit and testable.
Building a Complete MVVM Structure: Practical Implementation
Now let's assemble everything into a cohesive project structure. Modern MVVM applications often incorporate Clean Architecture principles, organizing code into distinct layers: presentation, domain, and data.
Project Structure
com.example.taskapp/
├── di/ # Dependency Injection (Hilt)
│ └── AppModule.kt
├── presentation/ # UI Layer (Compose)
│ ├── screens/
│ │ ├── TaskListScreen.kt
│ │ └── TaskDetailScreen.kt
│ ├── components/
│ │ ├── TaskItem.kt
│ │ └── TaskInputField.kt
│ └── viewmodel/
│ ├── TaskViewModel.kt
│ └── TaskDetailViewModel.kt
├── domain/ # Business Logic Layer
│ ├── model/
│ │ └── Task.kt
│ ├── repository/
│ │ └── TaskRepository.kt
│ └── usecase/
│ ├── GetAllTasksUseCase.kt
│ ├── AddTaskUseCase.kt
│ └── DeleteTaskUseCase.kt
└── data/ # Data Access Layer
├── local/
│ ├── TaskDatabase.kt
│ └── TaskDao.kt
└── repository/
└── TaskRepositoryImpl.kt
Implementation Example: Task Management App
1. Domain Layer - Define Your Business Rules
// domain/model/Task.kt
data class Task(
val id: Int,
val title: String,
val description: String,
val isCompleted: Boolean,
val createdAt: LocalDateTime
)
// domain/repository/TaskRepository.kt
interface TaskRepository {
fun getAllTasks(): Flow<List<Task>>
suspend fun getTaskById(id: Int): Task?
suspend fun addTask(task: Task)
suspend fun updateTask(task: Task)
suspend fun deleteTask(taskId: Int)
suspend fun completeTask(taskId: Int)
}
// domain/usecase/GetAllTasksUseCase.kt
class GetAllTasksUseCase(
private val repository: TaskRepository
) {
operator fun invoke(): Flow<List<Task>> = repository.getAllTasks()
}
2. Data Layer - Implement Data Access
// data/local/TaskDatabase.kt
@Database(entities = [TaskEntity::class], version = 1)
abstract class TaskDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
// data/local/TaskDao.kt
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY createdAt DESC")
fun getAllTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :id")
suspend fun getTaskById(id: Int): TaskEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity)
@Update
suspend fun updateTask(task: TaskEntity)
@Delete
suspend fun deleteTask(task: TaskEntity)
}
// data/repository/TaskRepositoryImpl.kt
class TaskRepositoryImpl(
private val taskDao: TaskDao
) : TaskRepository {
override fun getAllTasks(): Flow<List<Task>> =
taskDao.getAllTasks().map { entities ->
entities.map { it.toDomain() }
}
override suspend fun getTaskById(id: Int): Task? =
taskDao.getTaskById(id)?.toDomain()
override suspend fun addTask(task: Task) {
taskDao.insertTask(task.toEntity())
}
override suspend fun updateTask(task: Task) {
taskDao.updateTask(task.toEntity())
}
override suspend fun deleteTask(taskId: Int) {
val task = taskDao.getTaskById(taskId)
task?.let { taskDao.deleteTask(it) }
}
override suspend fun completeTask(taskId: Int) {
val task = taskDao.getTaskById(taskId)
task?.let {
taskDao.updateTask(it.copy(isCompleted = true))
}
}
}
3. Presentation Layer - ViewModel and Composables
// presentation/viewmodel/TaskViewModel.kt
@HiltViewModel
class TaskViewModel @Inject constructor(
private val getAllTasksUseCase: GetAllTasksUseCase,
private val addTaskUseCase: AddTaskUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase,
private val completeTaskUseCase: CompleteTaskUseCase
) : ViewModel() {
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _uiEvents = Channel<UIEvent>(Channel.BUFFERED)
val uiEvents: Flow<UIEvent> = _uiEvents.receiveAsFlow()
init {
loadTasks()
}
private fun loadTasks() {
viewModelScope.launch {
_isLoading.value = true
try {
getAllTasksUseCase().collect { taskList ->
_tasks.value = taskList
_error.value = null
}
} catch (e: Exception) {
_error.value = "Failed to load tasks"
_uiEvents.send(UIEvent.ShowSnackbar(e.message ?: "Unknown error"))
} finally {
_isLoading.value = false
}
}
}
fun addTask(title: String, description: String = "") {
viewModelScope.launch {
try {
val newTask = Task(
id = 0,
title = title,
description = description,
isCompleted = false,
createdAt = LocalDateTime.now()
)
addTaskUseCase(newTask)
_uiEvents.send(UIEvent.ShowSnackbar("Task added successfully"))
} catch (e: Exception) {
_uiEvents.send(UIEvent.ShowSnackbar("Failed to add task"))
}
}
}
fun deleteTask(taskId: Int) {
viewModelScope.launch {
try {
deleteTaskUseCase(taskId)
_uiEvents.send(UIEvent.ShowSnackbar("Task deleted"))
} catch (e: Exception) {
_uiEvents.send(UIEvent.ShowSnackbar("Failed to delete task"))
}
}
}
fun completeTask(taskId: Int) {
viewModelScope.launch {
try {
completeTaskUseCase(taskId)
} catch (e: Exception) {
_uiEvents.send(UIEvent.ShowSnackbar("Failed to update task"))
}
}
}
}
// presentation/screens/TaskListScreen.kt
@Composable
fun TaskListScreen(
viewModel: TaskViewModel = hiltViewModel(),
onNavigateToDetail: (taskId: Int) -> Unit
) {
val tasks by viewModel.tasks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.uiEvents.collect { event ->
when (event) {
is UIEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
onClick = { /* Navigate to add task */ }
) {
Icon(Icons.Default.Add, contentDescription = "Add Task")
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when {
isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
error != null -> {
ErrorCard(
error = error ?: "Unknown error",
onRetry = { viewModel.loadTasks() },
modifier = Modifier.align(Alignment.Center)
)
}
tasks.isEmpty() -> {
EmptyStateCard(
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
LazyColumn {
items(
items = tasks,
key = { it.id }
) { task ->
TaskCard(
task = task,
onDelete = { viewModel.deleteTask(task.id) },
onComplete = { viewModel.completeTask(task.id) },
onClick = { onNavigateToDetail(task.id) }
)
}
}
}
}
}
}
}
Best Practices for MVVM with Compose
1. Encapsulation is Critical
Always expose read-only StateFlow from your ViewModel while keeping MutableStateFlow private. This prevents the UI layer from bypassing the ViewModel's logic.
// ✅ Good
private val _uiState = MutableStateFlow<TaskListState>(TaskListState.Loading)
val uiState: StateFlow<TaskListState> = _uiState.asStateFlow()
// ❌ Bad - exposes mutability to UI layer
val uiState = MutableStateFlow<TaskListState>(TaskListState.Loading)
2. Use Data Classes for UI State
Create dedicated data classes to hold all UI state. This makes it clear what state your screen depends on and enables easier testing.
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val selectedTaskId: Int? = null
)
class TaskViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
}
3. Handle Side Effects Properly
Use Channels or StateFlow for one-time events (showing snackbars, navigation) separate from regular state.
private val _navigationEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navigationEvents: Flow<NavigationEvent> = _navigationEvents.receiveAsFlow()
fun navigateToDetail(taskId: Int) {
viewModelScope.launch {
_navigationEvents.send(NavigationEvent.NavigateToDetail(taskId))
}
}
4. Respect Coroutine Scopes
Always use viewModelScope for coroutines in ViewModels. It's tied to the ViewModel's lifecycle and automatically cancels when the ViewModel is destroyed.
// ✅ Good - automatically cancelled with ViewModel
viewModelScope.launch {
repository.fetchData().collect { data ->
_uiState.value = data
}
}
// ❌ Bad - potential memory leak if not manually cancelled
GlobalScope.launch {
repository.fetchData().collect { data ->
_uiState.value = data
}
}
5. Test Your ViewModels
The whole point of MVVM is testability. ViewModels should be simple to test because they don't depend on Android framework components directly.
class TaskViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun loadTasks_success() = runTest {
val mockRepository = mockk<TaskRepository>()
val tasks = listOf(
Task(1, "Task 1", "", false, LocalDateTime.now()),
Task(2, "Task 2", "", false, LocalDateTime.now())
)
coEvery { mockRepository.getAllTasks() } returns flowOf(tasks)
val viewModel = TaskViewModel(
GetAllTasksUseCase(mockRepository),
mockk(), mockk(), mockk()
)
advanceUntilIdle()
assertEquals(tasks, viewModel.tasks.value)
}
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Putting Business Logic in Composables
Composables should be pure and focused on UI. Business logic belongs in ViewModels or Use Cases.
// ❌ Bad - business logic in composable
@Composable
fun TaskScreen() {
var tasks by remember { mutableStateOf(emptyList<Task>()) }
LaunchedEffect(Unit) {
val db = Room.databaseBuilder(context, TaskDatabase::class.java, "tasks").build()
tasks = db.taskDao().getAllTasks().first()
}
}
// ✅ Good - logic in ViewModel
@Composable
fun TaskScreen(viewModel: TaskViewModel = hiltViewModel()) {
val tasks by viewModel.tasks.collectAsState()
// Just display state
}
Pitfall 2: Recreating ViewModels Unnecessarily
Always use hiltViewModel() or remember dependency creation to ensure you get the same ViewModel instance across recompositions.
// ❌ Bad - creates new ViewModel on every recomposition
@Composable
fun TaskScreen() {
val viewModel = TaskViewModel(repository)
}
// ✅ Good - same ViewModel instance across recompositions
@Composable
fun TaskScreen(viewModel: TaskViewModel = hiltViewModel()) {
}
Pitfall 3: Blocking Coroutines
Never use runBlocking in a ViewModel. Always use launch or async with proper scope.
// ❌ Bad - blocks thread
viewModelScope.launch {
val result = runBlocking { repository.fetchData() }
}
// ✅ Good - non-blocking
viewModelScope.launch {
val result = repository.fetchData() // suspend function
}
Conclusion: Bringing It All Together
Building structured Android apps with MVVM isn't just about following a pattern—it's about making intentional architectural decisions that pay dividends as your project grows. When you implement MVVM correctly with Kotlin and Jetpack Compose, you're creating applications that are:
- Testable: Business logic is isolated and independent of UI frameworks
- Maintainable: Changes to business logic don't require touching UI code
- Scalable: Clear separation of concerns makes it easy to add features without introducing bugs
- Reactive: State changes automatically propagate through StateFlow to your UI
- Developer-friendly: The patterns become second nature, reducing cognitive load
The combination of MVVM with Jetpack Compose represents the current best practice for Android development. StateFlow provides the reactive backbone, ViewModels manage state and orchestrate business logic, and Compose functions declaratively describe your UI based on that state.
Start with a simple application, apply these principles, and gradually add complexity. You'll quickly discover that the upfront investment in proper architecture pays for itself through easier debugging, faster feature development, and confidence that your code is doing what you intend.
The journey to structured Android development is ongoing, but with MVVM and Compose as your foundation, you're well-equipped to build apps that scale with your ambitions.