Skip to main content
Technology & EngineeringAndroid Kotlin285 lines

Architecture

MVVM architecture pattern with ViewModel, LiveData, and StateFlow for scalable Android apps

Quick Summary28 lines
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 lines
Paste into your CLAUDE.md or agent config

Architecture (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 StateFlow interface and keep the MutableStateFlow private.

  • Treating UI events as persistent state: Storing a "show error dialog" boolean in UiState that must be manually reset after display leads to the event reappearing on configuration changes. Use Channel with receiveAsFlow() for events that should fire once and be consumed.

  • Using stateIn without WhileSubscribed: stateIn with SharingStarted.Eagerly or SharingStarted.Lazily keeps upstream flows active forever, even when no one is collecting. Use WhileSubscribed(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. Use hiltViewModel() or viewModel() 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 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.

Common Pitfalls

  • 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).
  • Creating a new ViewModel instance on every recomposition by not using hiltViewModel() or viewModel().
  • Ignoring process death — ViewModel survives configuration changes but not process death. Use SavedStateHandle for critical transient state.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →