Your first Android app works fine with everything in one file. But as the app grows, things fall apart. You add more screens. More API calls. More database queries. Suddenly, one change breaks three unrelated features.
This is the architecture problem. And it is the reason most real-world apps follow a structured pattern.
In this tutorial, you will learn the three most important architecture patterns for Android: MVVM, MVI, and Clean Architecture. You will understand when to use each one and how they work together.
Prerequisites: This series assumes you know Kotlin basics and Compose fundamentals. If not, start with the Kotlin tutorial series and the Jetpack Compose tutorial series.
Why Architecture Matters
Without architecture, your code ends up like this:
@Composable
fun UserScreen() {
var users by remember { mutableStateOf<List<User>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
isLoading = true
try {
val db = Room.databaseBuilder(/*...*/).build()
val api = Retrofit.Builder().build().create(UserApi::class.java)
val response = api.getUsers()
db.userDao().insertAll(response)
users = db.userDao().getAll()
} catch (e: Exception) {
error = e.message
}
isLoading = false
}
// UI code mixed with business logic...
}
This Composable does everything: creates the database, calls the API, handles errors, and shows the UI. If you need the same data on another screen, you copy-paste all of this.
Architecture solves three problems:
- Separation of concerns — each class has one job
- Testability — you can test business logic without the UI
- Scalability — new features don’t break existing ones
The Three Layers
Google’s official architecture guide splits an Android app into three layers:
┌──────────────────┐
│ UI Layer │ Composables + ViewModel
├──────────────────┤
│ Domain Layer │ Use cases (optional but recommended)
├──────────────────┤
│ Data Layer │ Repositories + Data Sources (API, DB)
└──────────────────┘
Data flows up. Events flow down. This is called Unidirectional Data Flow (UDF).
- The UI sends events (user clicked a button) down to the ViewModel
- The ViewModel calls the domain/data layer
- Data flows back up to the UI as state
MVVM — Model View ViewModel
MVVM is the most common pattern on Android. It separates the UI from business logic using a ViewModel.
How It Works
- Model — data classes and repositories
- View — Composables that display data
- ViewModel — holds UI state and handles user actions
Example
// Data class (Model)
data class User(
val id: Long,
val name: String,
val email: String
)
// ViewModel
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
_isLoading.value = true
_users.value = repository.getUsers()
_isLoading.value = false
}
}
fun deleteUser(userId: Long) {
viewModelScope.launch {
repository.deleteUser(userId)
loadUsers()
}
}
}
// Composable (View)
@Composable
fun UserScreen(viewModel: UserViewModel) {
val users by viewModel.users.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
if (isLoading) {
CircularProgressIndicator()
} else {
LazyColumn {
items(users) { user ->
UserItem(
user = user,
onDelete = { viewModel.deleteUser(user.id) }
)
}
}
}
}
The Problem with MVVM
MVVM works well for simple screens. But as complexity grows, you get multiple MutableStateFlow fields in your ViewModel. You update _isLoading in five different places. You forget to set _error back to null. The state becomes inconsistent.
This is where MVI helps.
MVI — Model View Intent
MVI takes MVVM one step further. Instead of multiple state flows, you have one single state and explicit events (called intents).
How It Works
- Model — a single data class that represents the entire screen state
- View — Composables that render the state
- Intent — user actions represented as sealed classes
Example
// State — single source of truth for the screen
data class UserState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// Intent — every possible user action
sealed interface UserIntent {
data object LoadUsers : UserIntent
data class DeleteUser(val userId: Long) : UserIntent
data object RetryLoad : UserIntent
}
// ViewModel with MVI
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state.asStateFlow()
init {
handleIntent(UserIntent.LoadUsers)
}
fun handleIntent(intent: UserIntent) {
when (intent) {
is UserIntent.LoadUsers -> loadUsers()
is UserIntent.DeleteUser -> deleteUser(intent.userId)
is UserIntent.RetryLoad -> loadUsers()
}
}
private fun loadUsers() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val users = repository.getUsers()
_state.update { it.copy(users = users, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(
isLoading = false,
error = e.message ?: "Something went wrong"
)}
}
}
}
private fun deleteUser(userId: Long) {
viewModelScope.launch {
repository.deleteUser(userId)
loadUsers()
}
}
}
// Composable
@Composable
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> {
ErrorMessage(
message = state.error!!,
onRetry = { viewModel.handleIntent(UserIntent.RetryLoad) }
)
}
else -> {
LazyColumn {
items(state.users) { user ->
UserItem(
user = user,
onDelete = {
viewModel.handleIntent(UserIntent.DeleteUser(user.id))
}
)
}
}
}
}
}
Why MVI Works Better with Compose
Compose is declarative. You describe what the UI looks like for a given state. MVI gives you exactly one state object. This maps perfectly to Compose’s model.
With MVVM, you collect 3-4 separate flows. With MVI, you collect one.
MVVM vs MVI — When to Use Which
| Feature | MVVM | MVI |
|---|---|---|
| State management | Multiple StateFlows | Single state object |
| Complexity | Simpler for small screens | Better for complex screens |
| Debugging | Harder to track state changes | Easy — just log the state object |
| Boilerplate | Less | More (state class, intent class) |
| Best for | Settings screens, simple forms | Lists, search, multi-step flows |
My recommendation: Use MVI for most screens. The extra boilerplate pays off quickly when you need to debug state issues or add new features.
Clean Architecture
Clean Architecture is not an alternative to MVVM or MVI. It works on top of them. It defines how you organize your code into layers with clear boundaries.
The Three Layers in Detail
┌──────────────────────────────────────────┐
│ UI Layer (Presentation) │
│ - Composables │
│ - ViewModels (MVVM or MVI) │
│ - UI models / state classes │
├──────────────────────────────────────────┤
│ Domain Layer │
│ - Use cases (one per business operation) │
│ - Domain models │
│ - Repository interfaces │
├──────────────────────────────────────────┤
│ Data Layer │
│ - Repository implementations │
│ - API services (Retrofit) │
│ - Database (Room) │
│ - Data models (DTOs, entities) │
└──────────────────────────────────────────┘
The Dependency Rule
The most important rule: inner layers don’t know about outer layers.
- The domain layer does NOT import anything from the UI or data layer
- The data layer does NOT import anything from the UI layer
- The UI layer can import from the domain layer
How does the domain layer use the repository if it does not import the data layer? Interfaces.
// Domain layer — defines the interface
interface UserRepository {
suspend fun getUsers(): List<User>
suspend fun deleteUser(userId: Long)
}
// Data layer — implements the interface
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override suspend fun getUsers(): List<User> {
return try {
val remote = api.getUsers()
dao.insertAll(remote.map { it.toEntity() })
dao.getAll().map { it.toDomain() }
} catch (e: Exception) {
dao.getAll().map { it.toDomain() }
}
}
override suspend fun deleteUser(userId: Long) {
dao.deleteById(userId)
try {
api.deleteUser(userId)
} catch (_: Exception) {
// Will sync on next refresh
}
}
}
The ViewModel only knows about UserRepository (the interface). It does not know about Retrofit or Room. You can swap the entire data layer without touching the ViewModel.
Practical Example: Refactoring to Clean Architecture
Let’s take a messy ViewModel and refactor it step by step.
Before — Everything in ViewModel
class TaskViewModel(application: Application) : AndroidViewModel(application) {
private val db = Room.databaseBuilder(
application, AppDatabase::class.java, "tasks.db"
).build()
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
fun loadTasks() {
viewModelScope.launch {
_tasks.value = db.taskDao().getAll()
}
}
}
After — Clean Architecture with MVI
Step 1: Domain model and repository interface
// domain/model/Task.kt
data class Task(
val id: Long,
val title: String,
val isCompleted: Boolean
)
// domain/repository/TaskRepository.kt
interface TaskRepository {
fun getTasks(): Flow<List<Task>>
suspend fun toggleTask(taskId: Long)
}
Step 2: Data layer implementation
// data/local/TaskEntity.kt
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val isCompleted: Boolean
)
fun TaskEntity.toDomain() = Task(id, title, isCompleted)
// data/local/TaskDao.kt
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY id DESC")
fun getAll(): Flow<List<TaskEntity>>
@Query("UPDATE tasks SET isCompleted = NOT isCompleted WHERE id = :taskId")
suspend fun toggleCompleted(taskId: Long)
}
// data/repository/TaskRepositoryImpl.kt
class TaskRepositoryImpl(
private val dao: TaskDao
) : TaskRepository {
override fun getTasks(): Flow<List<Task>> {
return dao.getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun toggleTask(taskId: Long) {
dao.toggleCompleted(taskId)
}
}
Step 3: ViewModel with MVI
// presentation/TaskState.kt
data class TaskState(
val tasks: List<Task> = emptyList(),
val isLoading: Boolean = false
)
sealed interface TaskIntent {
data object LoadTasks : TaskIntent
data class ToggleTask(val taskId: Long) : TaskIntent
}
// presentation/TaskViewModel.kt
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repository: TaskRepository
) : ViewModel() {
private val _state = MutableStateFlow(TaskState())
val state: StateFlow<TaskState> = _state.asStateFlow()
init {
observeTasks()
}
fun handleIntent(intent: TaskIntent) {
when (intent) {
is TaskIntent.LoadTasks -> Unit // already started in init
is TaskIntent.ToggleTask -> toggleTask(intent.taskId)
}
}
private fun observeTasks() {
viewModelScope.launch {
repository.getTasks().collect { tasks ->
_state.update { it.copy(tasks = tasks, isLoading = false) }
}
}
}
private fun toggleTask(taskId: Long) {
viewModelScope.launch {
repository.toggleTask(taskId)
}
}
}
Now every layer has a single responsibility. The ViewModel does not know about Room. The repository does not know about the UI. You can test each piece independently.
Common Mistakes
1. God ViewModel
A ViewModel that does everything — API calls, database queries, validation, formatting. Split these into repositories and use cases.
2. Business Logic in Composables
// Bad — business logic in UI
@Composable
fun PriceText(price: Double, discount: Double) {
val finalPrice = price - (price * discount / 100) // Business logic!
Text("$${String.format("%.2f", finalPrice)}")
}
Move calculations to the ViewModel or a use case.
3. Leaking Context in ViewModel
Never pass Context, Activity, or Application to a ViewModel directly. Use Hilt’s @ApplicationContext if you absolutely need it. In most cases, you don’t.
4. Exposing Mutable State
// Bad — UI can modify state directly
val users = MutableStateFlow<List<User>>(emptyList())
// Good — expose immutable, keep mutable private
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
Android 16 Considerations
If your app targets API 36 (Android 16), keep these architectural concerns in mind:
- Edge-to-edge is mandatory — starting from Android 15 (API 35), all apps must handle
WindowInsetsproperly. Android 16 continues this requirement. Your architecture should handle insets in the UI layer, not in business logic. Check Compose Tutorial #20: Adaptive Layouts for details. - Predictive back gestures —
onBackPressed()was deprecated in API 33. UseOnBackPressedCallbackor Compose’sBackHandler. - Large screen support — apps on 600dp+ screens must provide adaptive layouts. Your ViewModel should expose the same state regardless of screen size. Only the Composable layer adapts.
Folder Structure
Here is a typical folder structure for a Clean Architecture Android app:
app/
├── data/
│ ├── local/
│ │ ├── TaskDao.kt
│ │ ├── TaskEntity.kt
│ │ └── AppDatabase.kt
│ ├── remote/
│ │ ├── TaskApi.kt
│ │ └── TaskDto.kt
│ └── repository/
│ └── TaskRepositoryImpl.kt
├── domain/
│ ├── model/
│ │ └── Task.kt
│ ├── repository/
│ │ └── TaskRepository.kt
│ └── usecase/
│ ├── GetTasksUseCase.kt
│ └── ToggleTaskUseCase.kt
└── presentation/
├── TaskState.kt
├── TaskIntent.kt
├── TaskViewModel.kt
└── TaskScreen.kt
In Tutorial #5, we will split this into separate Gradle modules for even better separation.
What’s Next?
In this tutorial, you learned:
- Why architecture matters for maintainable apps
- MVVM and how it separates UI from business logic
- MVI and how a single state object simplifies Compose apps
- Clean Architecture layers and the dependency rule
- How to refactor from messy code to clean layers
Next up: Android Tutorial #2: Dependency Injection with Hilt — where you will learn how to connect all these layers automatically using Hilt.
Related Articles
- Jetpack Compose Tutorial #9: ViewModel with Compose — ViewModel basics
- MVI with Jetpack Compose — MVI pattern in detail
- Kotlin Tutorial: Coroutines — async programming basics
- Kotlin Tutorial: Flow — reactive streams
This is part 1 of the Android Development Tutorial series.