Modern Android development demands scalable, maintainable, and testable architectures, and MVVM (Model-View-ViewModel) has emerged as the gold standard. It helps in structuring code in a way that ensures a clean separation of concerns, making it easier to manage UI, business logic, and data operations.
In this guide, we’ll take an in-depth look at MVVM, its benefits, how to implement it using Jetpack Compose, and advanced concepts like dependency injection, UI state handling, and testing. Let’s dive in!
What is MVVM?
MVVM (Model-View-ViewModel) is an architectural pattern that separates the presentation layer (UI) from the business logic and data handling. This separation enhances modularity, making the app easier to maintain and test.
MVVM Components
- Model: Represents the data layer (API, database, repositories) and business logic.
- View: The UI layer (Activity, Fragment, or Composable functions in Jetpack Compose).
- ViewModel: Acts as a bridge between View and Model, holding UI-related data and surviving configuration changes.
How MVVM Works
- The View observes data from the ViewModel.
- The ViewModel fetches data from the Model.
- The Model retrieves data from an API, database, or local cache.
- The ViewModel exposes the data, and the View updates accordingly.
Why Use MVVM?
- Separation of Concerns — Keeps UI and business logic separate.
- Better Testability — ViewModel can be unit tested without UI dependencies.
- Lifecycle Awareness — ViewModel survives configuration changes.
- Scalability — Works well with large-scale applications.
- Compatibility with Jetpack Compose — Supports modern UI development in Android.
Implementing MVVM in Android with Jetpack Compose
Let’s implement a simple MVVM architecture using Jetpack Compose.
Step 1: Model (Repository Layer)
class UserRepository {
fun getUsers(): List<String> {
return listOf("Amol", "Akshay", "Swapnil")
}
}
Step 2: ViewModel
class UserViewModel : ViewModel() {
private val repository = UserRepository()
private val _users = MutableStateFlow<List<String>>(emptyList())
val users: StateFlow<List<String>> = _users
init {
fetchUsers()
}
private fun fetchUsers() {
_users.value = repository.getUsers()
}
}
Step 3: View (Jetpack Compose UI)
@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
val users by viewModel.users.collectAsState()
LazyColumn {
items(users) { user ->
Text(text = user, fontSize = 20.sp, modifier = Modifier.padding(16.dp))
}
}
}
This basic example sets up MVVM with a repository, ViewModel, and a UI that observes data changes.
Dependency Injection in MVVM (Using Hilt)
To make the architecture scalable, we use Hilt for dependency injection.
Step 1: Add Dependencies
dependencies {
implementation "androidx.hilt:hilt-navigation-compose:1.1.0"
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
}
Step 2: Enable Hilt in the Application Class
@HiltAndroidApp
class MyApp : Application()
Step 3: Inject Repository into ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() {
private val _users = MutableStateFlow<List<String>>(emptyList())
val users: StateFlow<List<String>> = _users
init {
fetchUsers()
}
private fun fetchUsers() {
_users.value = repository.getUsers()
}
}
Step 4: Inject ViewModel into Composable
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val users by viewModel.users.collectAsState()
LazyColumn {
items(users) { user ->
Text(text = user, fontSize = 20.sp, modifier = Modifier.padding(16.dp))
}
}
}
LiveData vs StateFlow: Which One to Use?

Best Practice: Use StateFlow
with Jetpack Compose because it integrates better with collectAsState()
.
Handling UI State in MVVM
To manage loading, success, and error states:
sealed class UIState<out T> {
object Loading : UIState<Nothing>()
data class Success<T>(val data: T) : UIState<T>()
data class Error(val message: String) : UIState<Nothing>()
}
Modify ViewModel:
class UserViewModel : ViewModel() {
private val _users = MutableStateFlow<UIState<List<String>>>(UIState.Loading)
val users: StateFlow<UIState<List<String>>> = _users
fun fetchUsers() {
viewModelScope.launch {
try {
val data = repository.getUsers()
_users.value = UIState.Success(data)
} catch (e: Exception) {
_users.value = UIState.Error("Failed to load users")
}
}
}
}
In UI:
when (state) {
is UIState.Loading -> CircularProgressIndicator()
is UIState.Success -> LazyColumn { items(state.data) { user -> Text(user) } }
is UIState.Error -> Text(state.message, color = Color.Red)
}
Unit Testing MVVM Components
Unit testing is important in MVVM to ensure reliability.
Test ViewModel
Add testing dependencies:
testImplementation "junit:junit:4.13.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4"
testImplementation "io.mockk:mockk:1.13.3"
Create a UserViewModelTest
file:
@ExperimentalCoroutinesApi
class UserViewModelTest {
private lateinit var viewModel: UserViewModel
private val repository = mockk<UserRepository>()
@Before
fun setUp() {
every { repository.getUsers() } returns listOf("Amol", "Akshay", "Swapnil")
viewModel = UserViewModel(repository)
}
@Test
fun `fetchUsers updates users state correctly`() {
// We can also call the body of setUp() here, which is useful for individual test functions that need more customization.
assert(viewModel.users.value is UIState.Success)
}
}
This tests that fetchUsers()
properly updates the UI state.
Conclusion
MVVM architecture enhances modularity, testability, and scalability in Android development. By using Jetpack Compose, Hilt for DI, StateFlow for state management, and UI state handling, we can build robust and maintainable applications.