Setting up MVI Architecture for Android Applications
MVI (Model-View-Intent) is not simply an evolution of MVVM. It is a paradigm shift: instead of two-way data bindings, you get a unidirectional flow where UI state is predictable at any moment. This becomes especially important when a user simultaneously pulls a list down to refresh, taps a button, and a push notification arrives—three events that MVVM with mutable LiveData can process in an unpredictable order.
MVI Principles That Change the Debugging Approach
Single source of truth — UiState. The entire screen is described by one immutable structure. There is no isLoading = true in one place and showError() in another — there is UiState.Loading, UiState.Success(data), UiState.Error(message). The current screen state is always one object.
Intent — not Android Intent. In MVI, this is a user action: RefreshIntent, SearchIntent(query), LoadMoreIntent. ViewModel receives a stream of Intents and transforms them into states.
Reproducibility. If you know the initial state and the sequence of Intents, you can precisely reproduce the final state. This makes bug reports testable.
Implementation with Kotlin + Coroutines
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val error: String? = null,
val isRefreshing: Boolean = false
)
sealed class ProfileIntent {
data class Load(val userId: String) : ProfileIntent()
object Refresh : ProfileIntent()
data class Follow(val targetId: String) : ProfileIntent()
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getProfile: GetUserProfileUseCase,
private val followUser: FollowUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
fun processIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.Load -> loadProfile(intent.userId)
is ProfileIntent.Refresh -> refreshProfile()
is ProfileIntent.Follow -> followUser(intent.targetId)
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
getProfile(userId).fold(
onSuccess = { _state.update { s -> s.copy(isLoading = false, profile = it) } },
onFailure = { _state.update { s -> s.copy(isLoading = false, error = it.message) } }
)
}
}
}
In Composable:
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(userId) {
viewModel.processIntent(ProfileIntent.Load(userId))
}
A button sends viewModel.processIntent(ProfileIntent.Follow(targetId)) — with no direct UI mutation.
Side Effects: Channel for One-Time Events
StateFlow is not suitable for navigation and Toast display: these are not "states" but "events". For them, we use Channel or SharedFlow:
private val _effects = Channel<ProfileEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
sealed class ProfileEffect {
data class NavigateToEdit(val userId: String) : ProfileEffect()
data class ShowSnackbar(val message: String) : ProfileEffect()
}
In Fragment/Activity, we subscribe to effects in lifecycleScope.launch { viewModel.effects.collect { ... } }.
Comparison with MVVM in the Context of Complex Screens
| Characteristic | MVVM | MVI |
|---|---|---|
| State | Multiple StateFlow |
Single UiState |
| Predictability | Depends on discipline | Architecturally guaranteed |
| Concurrent events | Possible race conditions | Processed sequentially |
| Testability | Good | Excellent (Given/When/Then by states) |
| Learning curve | Low | Medium |
For simple CRUD screens, MVVM is sufficient. MVI is justified for: screens with multiple event sources, complex UI states with multiple flags, teams with high test coverage requirements.
Orbit MVI — Ready-Made Framework
Writing MVI from scratch on each project is duplication. Orbit MVI (orbit-mvi) is a library from Mobile Native Foundation that provides a concise DSL:
class ProfileViewModel : ContainerHost<ProfileUiState, ProfileEffect>, ViewModel() {
override val container = container<ProfileUiState, ProfileEffect>(ProfileUiState())
fun load(userId: String) = intent {
reduce { state.copy(isLoading = true) }
val profile = getProfile(userId).getOrThrow()
reduce { state.copy(isLoading = false, profile = profile) }
}
}
orbit-mvi is compatible with Hilt and tested well through the test { } block from orbit-testing.
What the Setup Includes
Choice of approach: manual implementation or Orbit MVI. Configuration of basic UiState/Intent/Effect contract. Implementation of a sample module with tests via turbine + kotlinx-coroutines-test. Team documentation with edge case handling examples.
Timeline
Setting up MVI from scratch (structure + first module with tests): 3–5 days. Migration of MVVM project to MVI: 2–4 weeks. Cost — after analysis.







