Jetpack Compose Clean Architecture: Complete MVVM Guide for Android

Have you ever stared at your Android codebase wondering how it became such an unmaintainable mess? Spaghetti code tangled with UI logic, business rules buried deep in Activities, and unit tests that seem impossible to write? You're not alone. Many Android developers face these challenges when their projects grow beyond simple prototypes.
The solution lies in combining jetpack compose clean architecture with the proven MVVM pattern. This powerful combination transforms chaotic codebases into well-structured, testable, and scalable applications that actually enjoy working on.
In this comprehensive guide, you'll discover how to architect Android applications using Clean Architecture principles, implement MVVM with Jetpack Compose, and create maintainable code that stands the test of time. We'll cover everything from theoretical foundations to practical implementation, complete with real code examples and testing strategies.
This guide is designed for intermediate to advanced Android developers who want to modernize their architecture approach and build production-ready applications. Whether you're refactoring existing projects or starting fresh, these patterns will elevate your development game.
Jetpack Compose Clean Architecture with MVVM represents the modern standard for Android development, combining declarative UI (Compose), robust architectural patterns (Clean Architecture), and proven presentation layer organization (MVVM) to create applications that are testable, maintainable, and scalable by design.
Part 1: Core Concepts Explained
1.1 Understanding Clean Architecture (The "Why")
Clean Architecture, introduced by Robert C. Martin, provides a blueprint for organizing code that prioritizes separation of concerns and dependency management. At its core, Clean Architecture follows these fundamental principles:
Separation of Concerns: Each layer has a single, well-defined responsibility. UI components handle presentation, business logic resides in use cases, and data access is isolated in repositories.
The Dependency Rule: Dependencies point inward toward the business logic. Outer layers can depend on inner layers, but never the reverse. This means your business logic remains independent of UI frameworks, databases, or external services.
Layer Structure:
- Entities: Core business objects and rules that are least likely to change
- Use Cases (Interactors): Application-specific business logic
- Interface Adapters: Convert data between use cases and external concerns
- Frameworks & Drivers: UI, databases, web frameworks, and external services
The benefits of this approach are transformative:
Testability: Business logic becomes easily unit testable since it's isolated from external dependencies. You can test your core functionality without databases or UI frameworks.
Maintainability: Changes to one layer don't cascade through the entire application. Need to switch from Room to Realm? Your business logic remains untouched.
Independence: Your core business logic doesn't know or care whether it's running in an Android app, a web service, or a command-line tool.
1.2 Model-View-ViewModel (MVVM) Explained (The "How" for UI)
MVVM serves as the architectural pattern for your presentation layer, fitting perfectly within Clean Architecture's interface adapters layer. Here's how each component functions:
Model: Represents the data and business logic. In our Clean Architecture context, this includes entities and use cases that ViewModels interact with.
View: The UI layer responsible for displaying data and capturing user interactions. With Jetpack Compose, this consists of composable functions that react to state changes.
ViewModel: Acts as a bridge between the View and Model layers. It exposes UI state, handles user interactions, and delegates business operations to use cases.
Data Binding and State Management: In the mvvm clean architecture android approach, ViewModels expose observable state (through StateFlow or LiveData) that Compose UIs automatically react to. This creates a unidirectional data flow where state changes trigger UI recomposition.
MVVM fits into Clean Architecture's presentation layer by serving as the interface adapter between your UI and business logic. ViewModels depend on use cases (domain layer) but remain independent of specific UI implementations.
The key question MVVM answers is: How do we organize UI-related code to be testable and maintainable? By separating presentation logic from UI implementation, we achieve both goals.
1.3 Jetpack Compose Essentials (The "What" for UI)
Jetpack Compose revolutionizes Android UI development through its declarative paradigm. Instead of imperatively manipulating views, you describe what the UI should look like for any given state.
Declarative UI Paradigm: Compose functions describe the UI as a function of state. When state changes, Compose automatically updates the UI to match the new state.
Core Concepts:
- Composable Functions: Building blocks of Compose UI, annotated with
@Composable
- State Management: Using
remember
andmutableStateOf
to manage local state - Recomposition: Automatic UI updates when state changes
Integration with ViewModels: Compose seamlessly observes ViewModel state through collectAsState()
or observeAsState()
, creating reactive UIs that automatically update when business state changes.
Why Jetpack Compose excels with MVVM: Compose's reactive nature aligns perfectly with MVVM's state-driven approach. ViewModels expose state, Compose observes it, and recomposition handles UI updates automatically. This eliminates the boilerplate of manual view updates and reduces bugs related to inconsistent UI state.
The combination creates a powerful development experience where UI accurately reflects business state without manual intervention.
Part 2: Setting Up the Project
2.1 Project Structure for Clean Architecture
A well-organized project structure is crucial for maintaining Clean Architecture principles. Here's the recommended multi-module approach:
Module Breakdown:
- app: Android application module containing UI, dependency injection setup, and application class
- domain: Pure Kotlin module with entities, use cases, and repository interfaces
- data: Android module implementing repository interfaces and handling data sources
This structure enforces the dependency rule by preventing the domain module from accessing Android-specific code while allowing outer layers to depend on inner ones.
Directory Organization:
app/
├── src/main/java/
│ ├── ui/
│ │ ├── screens/
│ │ ├── components/
│ │ └── theme/
│ ├── viewmodels/
│ ├── di/
│ └── MainActivity.kt
domain/
├── src/main/java/
│ ├── entities/
│ ├── usecases/
│ └── repositories/
data/
├── src/main/java/
│ ├── repositories/
│ ├── datasources/
│ │ ├── remote/
│ │ └── local/
│ └── mappers/
2.2 Essential Dependencies
Here are the key dependencies for implementing jetpack compose mvvm tutorial architecture:
// App-level build.gradle
dependencies {
// Jetpack Compose
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material3:material3:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation "androidx.activity:activity-compose:1.8.2"
// ViewModel & Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.7.0"
// Navigation
implementation "androidx.navigation:navigation-compose:2.7.6"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
// Dependency Injection
implementation "com.google.dagger:hilt-android:2.48"
kapt "com.google.dagger:hilt-compiler:2.48"
implementation "androidx.hilt:hilt-navigation-compose:1.1.0"
// Networking
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
// Local Database
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.room:room-ktx:2.6.1"
kapt "androidx.room:room-compiler:2.6.1"
}
2.3 Defining Base Classes (Optional but Recommended)
To reduce boilerplate and ensure consistency, consider creating base classes:
// Base UseCase
abstract class UseCase<in P, R> {
abstract suspend operator fun invoke(parameters: P): R
}
// Base ViewModel
abstract class BaseViewModel : ViewModel() {
protected fun <T> launch(block: suspend () -> T) {
viewModelScope.launch {
try {
block()
} catch (e: Exception) {
handleError(e)
}
}
}
protected abstract fun handleError(error: Exception)
}
Part 3: Implementing Each Layer
Let's build a simple Todo List application to demonstrate these concepts in practice.
3.1 Domain Layer (The Core Logic)
Entities represent our core business objects:
// domain/entities/TodoItem.kt
data class TodoItem(
val id: String,
val title: String,
val description: String,
val isCompleted: Boolean,
val createdAt: Long,
val dueDate: Long?
)
Use Cases encapsulate business logic with single responsibilities:
// domain/usecases/GetTodosUseCase.kt
class GetTodosUseCase @Inject constructor(
private val repository: TodoRepository
) : UseCase<Unit, Flow<List<TodoItem>>>() {
override suspend operator fun invoke(parameters: Unit): Flow<List<TodoItem>> {
return repository.getTodos()
}
}
// domain/usecases/AddTodoItemUseCase.kt
class AddTodoItemUseCase @Inject constructor(
private val repository: TodoRepository
) : UseCase<AddTodoItemUseCase.Params, Result<Unit>>() {
data class Params(
val title: String,
val description: String,
val dueDate: Long?
)
override suspend operator fun invoke(parameters: Params): Result<Unit> {
return try {
val todoItem = TodoItem(
id = generateId(),
title = parameters.title,
description = parameters.description,
isCompleted = false,
createdAt = System.currentTimeMillis(),
dueDate = parameters.dueDate
)
repository.addTodo(todoItem)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun generateId(): String = UUID.randomUUID().toString()
}
Repository Interfaces define data access contracts:
// domain/repositories/TodoRepository.kt
interface TodoRepository {
suspend fun getTodos(): Flow<List<TodoItem>>
suspend fun addTodo(todo: TodoItem)
suspend fun updateTodo(todo: TodoItem)
suspend fun deleteTodo(id: String)
suspend fun getTodoById(id: String): TodoItem?
}
3.2 Data Layer (Data Sources & Implementations)
Repository Implementation coordinates between data sources:
// data/repositories/TodoRepositoryImpl.kt
@Singleton
class TodoRepositoryImpl @Inject constructor(
private val localDataSource: TodoLocalDataSource,
private val remoteDataSource: TodoRemoteDataSource,
private val mapper: TodoMapper
) : TodoRepository {
override suspend fun getTodos(): Flow<List<TodoItem>> {
return localDataSource.getTodos()
.map { entities ->
entities.map { mapper.toDomain(it) }
}
}
override suspend fun addTodo(todo: TodoItem) {
val entity = mapper.toEntity(todo)
localDataSource.insertTodo(entity)
// Sync with remote if needed
try {
remoteDataSource.createTodo(mapper.toDto(todo))
} catch (e: Exception) {
// Handle sync error
}
}
// ... other methods
}
Data Sources handle specific data access:
// data/datasources/local/TodoLocalDataSource.kt
@Singleton
class TodoLocalDataSource @Inject constructor(
private val dao: TodoDao
) {
fun getTodos(): Flow<List<TodoEntity>> = dao.getAllTodos()
suspend fun insertTodo(todo: TodoEntity) = dao.insert(todo)
suspend fun updateTodo(todo: TodoEntity) = dao.update(todo)
suspend fun deleteTodo(id: String) = dao.deleteById(id)
}
// data/datasources/remote/TodoRemoteDataSource.kt
@Singleton
class TodoRemoteDataSource @Inject constructor(
private val apiService: TodoApiService
) {
suspend fun getTodos(): List<TodoDto> = apiService.getTodos()
suspend fun createTodo(todo: TodoDto): TodoDto = apiService.createTodo(todo)
// ... other methods
}
Data Mapping converts between layers:
// data/mappers/TodoMapper.kt
@Singleton
class TodoMapper {
fun toDomain(entity: TodoEntity): TodoItem {
return TodoItem(
id = entity.id,
title = entity.title,
description = entity.description,
isCompleted = entity.isCompleted,
createdAt = entity.createdAt,
dueDate = entity.dueDate
)
}
fun toEntity(domain: TodoItem): TodoEntity {
return TodoEntity(
id = domain.id,
title = domain.title,
description = domain.description,
isCompleted = domain.isCompleted,
createdAt = domain.createdAt,
dueDate = domain.dueDate
)
}
fun toDto(domain: TodoItem): TodoDto {
return TodoDto(
id = domain.id,
title = domain.title,
description = domain.description,
isCompleted = domain.isCompleted,
createdAt = domain.createdAt,
dueDate = domain.dueDate
)
}
}
3.3 Presentation Layer (MVVM & Jetpack Compose)
ViewModels coordinate between UI and business logic:
// app/viewmodels/TodoListViewModel.kt
@HiltViewModel
class TodoListViewModel @Inject constructor(
private val getTodosUseCase: GetTodosUseCase,
private val addTodoItemUseCase: AddTodoItemUseCase,
private val toggleTodoUseCase: ToggleTodoStatusUseCase
) : BaseViewModel() {
private val _uiState = MutableStateFlow(TodoListUiState())
val uiState: StateFlow<TodoListUiState> = _uiState.asStateFlow()
init {
loadTodos()
}
fun onEvent(event: TodoListEvent) {
when (event) {
is TodoListEvent.AddTodo -> addTodo(event.title, event.description)
is TodoListEvent.ToggleTodo -> toggleTodo(event.id)
is TodoListEvent.RefreshTodos -> loadTodos()
}
}
private fun loadTodos() {
launch {
getTodosUseCase(Unit).collect { todos ->
_uiState.value = _uiState.value.copy(
todos = todos,
isLoading = false
)
}
}
}
private fun addTodo(title: String, description: String) {
launch {
_uiState.value = _uiState.value.copy(isLoading = true)
val result = addTodoItemUseCase(
AddTodoItemUseCase.Params(title, description, null)
)
if (result.isFailure) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = result.exceptionOrNull()?.message
)
}
}
}
private fun toggleTodo(id: String) {
launch {
toggleTodoUseCase(ToggleTodoStatusUseCase.Params(id))
}
}
override fun handleError(error: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message
)
}
}
// UI State
data class TodoListUiState(
val todos: List<TodoItem> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null
)
// UI Events
sealed class TodoListEvent {
data class AddTodo(val title: String, val description: String) : TodoListEvent()
data class ToggleTodo(val id: String) : TodoListEvent()
object RefreshTodos : TodoListEvent()
}
Jetpack Compose UI observes ViewModel state:
// app/ui/screens/TodoListScreen.kt
@Composable
fun TodoListScreen(
viewModel: TodoListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
TodoListContent(
uiState = uiState,
onEvent = viewModel::onEvent
)
}
@Composable
private fun TodoListContent(
uiState: TodoListUiState,
onEvent: (TodoListEvent) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
AddTodoSection(
onAddTodo = { title, description ->
onEvent(TodoListEvent.AddTodo(title, description))
}
)
Spacer(modifier = Modifier.height(16.dp))
when {
uiState.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
uiState.error != null -> {
ErrorMessage(
message = uiState.error,
onRetry = { onEvent(TodoListEvent.RefreshTodos) }
)
}
else -> {
LazyColumn {
items(uiState.todos) { todo ->
TodoItem(
todo = todo,
onToggle = { onEvent(TodoListEvent.ToggleTodo(todo.id)) }
)
}
}
}
}
}
}
@Composable
private fun TodoItem(
todo: TodoItem,
onToggle: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = todo.title,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (todo.isCompleted) {
TextDecoration.LineThrough
} else {
TextDecoration.None
}
)
if (todo.description.isNotEmpty()) {
Text(
text = todo.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
Part 4: Testing Strategies
Testing is where Clean Architecture truly shines. The separation of concerns makes each layer independently testable.
4.1 Unit Testing Domain Layer
// domain/usecases/AddTodoItemUseCaseTest.kt
class AddTodoItemUseCaseTest {
@Mock
private lateinit var repository: TodoRepository
private lateinit var useCase: AddTodoItemUseCase
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
useCase = AddTodoItemUseCase(repository)
}
@Test
fun `invoke should add todo item successfully`() = runTest {
// Given
val params = AddTodoItemUseCase.Params("Test Title", "Test Description", null)
// When
val result = useCase(params)
// Then
assertTrue(result.isSuccess)
verify(repository).addTodo(any())
}
}
4.2 Unit Testing Data Layer
// data/repositories/TodoRepositoryImplTest.kt
class TodoRepositoryImplTest {
@Mock
private lateinit var localDataSource: TodoLocalDataSource
@Mock
private lateinit var remoteDataSource: TodoRemoteDataSource
@Mock
private lateinit var mapper: TodoMapper
private lateinit var repository: TodoRepositoryImpl
@Test
fun `getTodos should return mapped domain objects`() = runTest {
// Given
val entities = listOf(createTodoEntity())
val domainItems = listOf(createTodoItem())
whenever(localDataSource.getTodos()).thenReturn(flowOf(entities))
whenever(mapper.toDomain(any())).thenReturn(domainItems.first())
// When
val result = repository.getTodos().first()
// Then
assertEquals(domainItems, result)
}
}
4.3 Unit Testing ViewModels
Testing ViewModels in MVVM Clean Architecture focuses on state management and use case interactions:
// app/viewmodels/TodoListViewModelTest.kt
class TodoListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Mock
private lateinit var getTodosUseCase: GetTodosUseCase
@Mock
private lateinit var addTodoItemUseCase: AddTodoItemUseCase
private lateinit var viewModel: TodoListViewModel
@Test
fun `initial state should be loading`() {
// Given
whenever(getTodosUseCase(Unit)).thenReturn(flowOf(emptyList()))
// When
viewModel = TodoListViewModel(getTodosUseCase, addTodoItemUseCase, mockk())
// Then
val initialState = viewModel.uiState.value
assertTrue(initialState.isLoading)
}
@Test
fun `addTodo event should call use case`() = runTest {
// Given
val todos = listOf(createTodoItem())
whenever(getTodosUseCase(Unit)).thenReturn(flowOf(todos))
whenever(addTodoItemUseCase(any())).thenReturn(Result.success(Unit))
viewModel = TodoListViewModel(getTodosUseCase, addTodoItemUseCase, mockk())
// When
viewModel.onEvent(TodoListEvent.AddTodo("Test", "Description"))
// Then
verify(addTodoItemUseCase).invoke(any())
}
}
4.4 UI Testing Jetpack Compose Screens
// app/ui/screens/TodoListScreenTest.kt
@HiltAndroidTest
class TodoListScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun todoListScreen_displaysTodos() {
// Given
val todos = listOf(
TodoItem("1", "Task 1", "Description 1", false, 0L, null),
TodoItem("2", "Task 2", "Description 2", true, 0L, null)
)
composeTestRule.setContent {
TodoListContent(
uiState = TodoListUiState(todos = todos, isLoading = false),
onEvent = {}
)
}
// Then
composeTestRule.onNodeWithText("Task 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Task 2").assertIsDisplayed()
}
}
Part 5: Advanced Topics & Best Practices
Error Handling Strategy
Implement a consistent error handling approach across layers:
// domain/common/Result.kt
sealed class DomainResult<out T> {
data class Success<T>(val data: T) : DomainResult<T>()
data class Error(val exception: DomainException) : DomainResult<Nothing>()
}
// Use in ViewModels
private fun handleResult<T>(
result: DomainResult<T>,
onSuccess: (T) -> Unit,
onError: (String) -> Unit
) {
when (result) {
is DomainResult.Success -> onSuccess(result.data)
is DomainResult.Error -> onError(result.exception.message)
}
}
Managing Complex UI State
For complex screens, consider using sealed classes for state:
sealed class TodoDetailUiState {
object Loading : TodoDetailUiState()
data class Success(
val todo: TodoItem,
val isEditing: Boolean = false,
val validationErrors: Map<String, String> = emptyMap()
) : TodoDetailUiState()
data class Error(val message: String) : TodoDetailUiState()
}
Flow vs LiveData for Compose
StateFlow is generally preferred over LiveData for Compose because:
- Better coroutine integration
- Immediate value access
- More predictable behavior with Compose's recomposition
Single Source of Truth
Ensure your repository serves as the single source of truth by implementing proper caching strategies:
class TodoRepositoryImpl {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
override suspend fun getTodos(): Flow<List<TodoItem>> {
// Always emit cached data first, then refresh
refreshTodos()
return _todos.asStateFlow()
}
private suspend fun refreshTodos() {
try {
val remoteTodos = remoteDataSource.getTodos()
localDataSource.insertTodos(remoteTodos.map { mapper.toEntity(it) })
_todos.value = remoteTodos.map { mapper.toDomain(it) }
} catch (e: Exception) {
// Fall back to local data
val localTodos = localDataSource.getTodos().first()
_todos.value = localTodos.map { mapper.toDomain(it) }
}
}
}
Common Pitfalls and Solutions
Pitfall: Passing Android Context to domain layer Solution: Use dependency injection to provide Android-specific implementations
Pitfall: Heavy business logic in ViewModels Solution: Move complex logic to use cases
Pitfall: Direct database access from ViewModels Solution: Always go through repository abstractions
Pitfall: Forgetting to handle loading states Solution: Always model loading, success, and error states explicitly
Part 6: Conclusion & Future Steps
Implementing jetpack compose clean architecture with MVVM transforms your Android development experience. This architectural approach delivers testable code that's easier to maintain, debug, and extend. The clear separation of concerns means team members can work on different layers simultaneously without conflicts.
The benefits compound as your application grows. What starts as slightly more setup code becomes a massive productivity multiplier when you need to add features, fix bugs, or adapt to new requirements. Your future self will thank you for the structured foundation.
The combination of Clean Architecture's organizational principles, MVVM's presentation layer clarity, and Jetpack Compose's reactive UI creates applications that are both robust and enjoyable to develop. This isn't just about following best practices—it's about building software that stands the test of time.
Ready to implement these patterns in your next project? Start small with a single feature, apply these principles, and gradually expand the architecture throughout your application. The investment in proper architecture pays dividends in reduced bugs, faster development cycles, and more maintainable code.
Have questions about implementing these patterns? Share your experiences in the comments below, and don't forget to share this guide with fellow Android developers who could benefit from modern architectural approaches.
Ready to master advanced Android development? Subscribe for more in-depth guides on Clean Architecture, Jetpack Compose, and modern Android development patterns.