Architecture
MVVM architecture pattern with ViewModel, LiveData, and StateFlow for scalable Android apps
You are an expert in MVVM architecture with ViewModel and LiveData/StateFlow for building Android applications with Kotlin.
## Key Points
- Model the entire screen state as a single `UiState` sealed interface or data class — the UI is a pure function of this state.
- Use `StateFlow` with `SharingStarted.WhileSubscribed(5000)` to keep upstream flows alive through configuration changes without leaking.
- Separate one-shot events (navigation, toasts) from persistent UI state — use `Channel` for events, `StateFlow` for state.
- Keep ViewModels free of Android framework references (`Context`, `View`, `Activity`). Inject `@ApplicationContext` only when absolutely necessary.
- Use the repository pattern to abstract data sources from the ViewModel.
- Prefer `StateFlow` over `LiveData` in new projects for full coroutines integration and Compose compatibility.
- Use `SavedStateHandle` in ViewModels for state that must survive process death.
- Putting business logic in the View layer (Composables or Fragments) instead of the ViewModel or domain layer.
- Using `LiveData` transformations with heavy computation on the main thread — switch to Flow with `flowOn(Dispatchers.Default)`.
- Not using `WhileSubscribed` with `stateIn`, causing upstream flows to remain active when no one is collecting.
- Exposing `MutableStateFlow` publicly — always expose the read-only `StateFlow` interface.
- Treating UI events as state (e.g., storing a "show snackbar" boolean in `UiState` that must be manually reset).
## Quick Example
```kotlin
sealed interface UserListUiState {
data object Loading : UserListUiState
data class Success(val users: List<User>) : UserListUiState
data class Error(val message: String) : UserListUiState
}
```skilldb get android-kotlin-skills/ArchitectureFull skill: 285 linesArchitecture (MVVM) — Android/Kotlin
You are an expert in MVVM architecture with ViewModel and LiveData/StateFlow for building Android applications with Kotlin.
Core Philosophy
Android architecture is a response to a fundamental platform constraint: the system can destroy and recreate your Activity at any time. Configuration changes, process death, and memory pressure mean that UI state cannot live in the Activity or Fragment itself. The ViewModel exists specifically to survive configuration changes, and SavedStateHandle exists to survive process death. Good Android architecture is built on accepting this lifecycle reality and designing state ownership accordingly, rather than fighting it with hacks like configChanges flags.
The UI should be a pure function of state. Define a single sealed interface or data class that represents every possible state of a screen -- loading, success, error, empty -- and have the Composable or Fragment render it deterministically. When the UI is a pure function of UiState, there are no race conditions between "loading finished" and "error shown," no boolean flags that get out of sync, and no impossible states. If the state is correct, the UI is correct.
One-shot events (navigation, snackbars, toasts) are fundamentally different from persistent state and must be handled differently. Storing "show snackbar" as a boolean in UiState means the snackbar reappears on every configuration change until someone manually resets the flag. Use a Channel or SharedFlow for events that should be consumed exactly once, keeping them separate from the state that drives the UI.
Anti-Patterns
-
Putting business logic in Composables or Fragments: Views should read state and forward user actions. When a Composable contains repository calls, data transformations, or conditional business logic, it becomes untestable without rendering UI and tightly coupled to the presentation layer.
-
Exposing MutableStateFlow publicly from the ViewModel: Leaking the mutable version allows any consumer to modify the state directly, bypassing the ViewModel's logic. Always expose the read-only
StateFlowinterface and keep theMutableStateFlowprivate. -
Treating UI events as persistent state: Storing a "show error dialog" boolean in
UiStatethat must be manually reset after display leads to the event reappearing on configuration changes. UseChannelwithreceiveAsFlow()for events that should fire once and be consumed. -
Using stateIn without WhileSubscribed:
stateInwithSharingStarted.EagerlyorSharingStarted.Lazilykeeps upstream flows active forever, even when no one is collecting. UseWhileSubscribed(5000)to keep the upstream alive briefly during configuration changes without leaking resources. -
Creating a new ViewModel instance on every recomposition: Calling
ViewModel()directly in a Composable creates a fresh instance on each recomposition, losing all state. UsehiltViewModel()orviewModel()to get the lifecycle-scoped instance that survives recomposition and configuration changes.
Overview
The Model-View-ViewModel (MVVM) pattern is the recommended architecture for Android apps. The View (Activity, Fragment, or Composable) observes state from the ViewModel, which in turn delegates business logic to the Model layer (repositories, data sources). This separation makes code testable, maintainable, and resilient to configuration changes.
Core Concepts
UI State Modeling
Define a sealed interface or data class that represents every possible state of a screen.
sealed interface UserListUiState {
data object Loading : UserListUiState
data class Success(val users: List<User>) : UserListUiState
data class Error(val message: String) : UserListUiState
}
ViewModel with StateFlow
@HiltViewModel
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UserListUiState>(UserListUiState.Loading)
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
_uiState.value = UserListUiState.Loading
try {
val users = repository.getUsers()
_uiState.value = UserListUiState.Success(users)
} catch (e: Exception) {
_uiState.value = UserListUiState.Error(e.message ?: "Unknown error")
}
}
}
fun deleteUser(userId: Long) {
viewModelScope.launch {
repository.deleteUser(userId)
loadUsers()
}
}
}
Reactive ViewModel with Flow
For data that streams from a database or other reactive source:
@HiltViewModel
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
val uiState: StateFlow<UserListUiState> = repository
.observeUsers()
.map<List<User>, UserListUiState> { UserListUiState.Success(it) }
.catch { emit(UserListUiState.Error(it.message ?: "Unknown error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserListUiState.Loading
)
}
UI Events (One-Shot)
For navigation, snackbars, and other one-time events, use a Channel or SharedFlow.
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val _events = Channel<LoginEvent>(Channel.BUFFERED)
val events: Flow<LoginEvent> = _events.receiveAsFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = authRepository.login(email, password)
_uiState.update { it.copy(isLoading = false) }
when (result) {
is Result.Success -> _events.send(LoginEvent.NavigateToHome)
is Result.Error -> _events.send(LoginEvent.ShowError(result.message))
}
}
}
}
data class LoginUiState(
val isLoading: Boolean = false
)
sealed interface LoginEvent {
data object NavigateToHome : LoginEvent
data class ShowError(val message: String) : LoginEvent
}
Collecting Events in Compose
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
is LoginEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
LoginContent(
uiState = uiState,
onLogin = viewModel::login,
modifier = Modifier.padding(padding)
)
}
}
Implementation Patterns
Layered Architecture
UI Layer (Composables / Fragments)
|
ViewModel (StateFlow, events)
|
Domain Layer (Use Cases) — optional
|
Data Layer (Repository)
|
Data Sources (API, Room, DataStore)
Repository Pattern
interface UserRepository {
fun observeUsers(): Flow<List<User>>
suspend fun getUsers(): List<User>
suspend fun getUser(id: Long): User
suspend fun deleteUser(id: Long)
}
class UserRepositoryImpl @Inject constructor(
private val api: ApiService,
private val dao: UserDao
) : UserRepository {
override fun observeUsers(): Flow<List<User>> =
dao.observeAll().map { entities -> entities.map { it.toDomain() } }
override suspend fun getUsers(): List<User> {
val remote = api.getUsers()
dao.upsertAll(remote.map { it.toEntity() })
return dao.getAll().map { it.toDomain() }
}
override suspend fun getUser(id: Long): User =
dao.getById(id)?.toDomain() ?: throw NotFoundException("User $id not found")
override suspend fun deleteUser(id: Long) {
api.deleteUser(id)
dao.deleteById(id)
}
}
Use Cases (Optional Domain Layer)
class GetFilteredUsersUseCase @Inject constructor(
private val repository: UserRepository
) {
operator fun invoke(filter: UserFilter): Flow<List<User>> =
repository.observeUsers().map { users ->
users.filter { user ->
(filter.role == null || user.role == filter.role) &&
(filter.query.isBlank() || user.name.contains(filter.query, ignoreCase = true))
}
}
}
SavedStateHandle for Process Death
@HiltViewModel
class SearchViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val repository: UserRepository
) : ViewModel() {
val query = savedStateHandle.getStateFlow("query", "")
val results: StateFlow<List<User>> = query
.debounce(300)
.flatMapLatest { q ->
if (q.length < 2) flowOf(emptyList())
else repository.searchUsers(q)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun onQueryChanged(newQuery: String) {
savedStateHandle["query"] = newQuery
}
}
Best Practices
- Model the entire screen state as a single
UiStatesealed interface or data class — the UI is a pure function of this state. - Use
StateFlowwithSharingStarted.WhileSubscribed(5000)to keep upstream flows alive through configuration changes without leaking. - Separate one-shot events (navigation, toasts) from persistent UI state — use
Channelfor events,StateFlowfor state. - Keep ViewModels free of Android framework references (
Context,View,Activity). Inject@ApplicationContextonly when absolutely necessary. - Use the repository pattern to abstract data sources from the ViewModel.
- Prefer
StateFlowoverLiveDatain new projects for full coroutines integration and Compose compatibility. - Use
SavedStateHandlein ViewModels for state that must survive process death.
Common Pitfalls
- Putting business logic in the View layer (Composables or Fragments) instead of the ViewModel or domain layer.
- Using
LiveDatatransformations with heavy computation on the main thread — switch to Flow withflowOn(Dispatchers.Default). - Not using
WhileSubscribedwithstateIn, causing upstream flows to remain active when no one is collecting. - Exposing
MutableStateFlowpublicly — always expose the read-onlyStateFlowinterface. - Treating UI events as state (e.g., storing a "show snackbar" boolean in
UiStatethat must be manually reset). - Creating a new ViewModel instance on every recomposition by not using
hiltViewModel()orviewModel(). - Ignoring process death —
ViewModelsurvives configuration changes but not process death. UseSavedStateHandlefor critical transient state.
Install this skill directly: skilldb add android-kotlin-skills
Related Skills
Coroutines
Kotlin coroutines and Flow for asynchronous programming and reactive streams in Android
Dependency Injection
Hilt and Dagger dependency injection for managing object creation and scoping in Android apps
Jetpack Compose
Jetpack Compose declarative UI toolkit for building native Android interfaces with Kotlin
Navigation
Jetpack Navigation component for type-safe in-app navigation and deep linking in Android
Retrofit
Retrofit HTTP client for type-safe REST API communication in Android with Kotlin coroutines
Room Database
Room persistence library for local SQLite database access with compile-time query verification in Android