
TL;DR: Android architecture didn’t start “clean.” We journeyed from Activities doing everything, through MVP/MVVM/Clean, the RxJava & coroutines eras, and Compose’s unidirectional data flow — landing on MVI with a State Machine as a robust, testable, and predictable approach for complex apps.
Why This Matters
Your users don’t care about architecture — but your team velocity, bug rate, testability, and feature complexity absolutely do. The last 15 years of Android development taught us that UI frameworks change (Views → Compose), but sound architecture outlives frameworks.
A High-Level Timeline
- 2010–2013: “God Activities/Fragments,” AsyncTask, Loaders → little to no formal architecture
- 2013–2016: MVP (Model-View-Presenter) dominates for testable UIs
- 2014–2017: Clean Architecture arrives (layers/use cases), DI with Dagger takes off
- 2016–2019: MVVM rises with Android Architecture Components (ViewModel, LiveData), Repositories
- 2017–2020: RxJava era standardizes reactive thinking; early MVI/Unidirectional Data Flow ideas appear
- 2019–2022: Kotlin Coroutines + Flow replace most Rx in new codebases; MVVM becomes the default
- 2020–2025: Jetpack Compose makes UDF mainstream; MVI variants (pure reducer, state machine) mature
- 2025: For complex apps, MVI with a State Machine becomes a leading endgame: explicit states, predictable transitions, great testability
Phase 1: The Pre-Architecture Era (2010–2013)
What it looked like
- Activities/Fragments fetched data, handled lifecycle, built UI, navigated, and did business logic.
- AsyncTask, Loaders, Handlers everywhere.
- Tight coupling to the Android framework; almost no unit tests.
Pain
- Spaghetti code, lifecycle bugs, hard to test, impossible to scale.
Why it mattered
- We learned: separate concerns or drown in complexity.
Phase 2: MVP (2013–2016)
Core idea
- View (Activity/Fragment) displays data and forwards events.
- Presenter contains UI logic, talks to Model/Use Cases, updates View.
- Model holds business/data logic.
Typical snippet
interface LoginView {
fun showLoading()
fun showError(msg: String)
fun showSuccess(user: User)
}
class LoginPresenter(
private val authRepo: AuthRepository,
private val view: LoginView
) {
fun onLoginClick(email: String, pass: String) {
view.showLoading()
authRepo.login(email, pass,
onSuccess = { view.showSuccess(it) },
onError = { view.showError(it.message ?: "Unknown error") }
)
}
}
Pros
- Testable presenters, thinner Activities.
Cons
- Large presenter classes, callback pyramids, tricky lifecycle handling.
- Still often framework-coupled Views.
Why it mattered
- First mass adoption of separation of concerns on Android.
🧱 Phase 3: Clean Architecture & Dependency Injection (2014–2017)
When Android apps started scaling, we needed structure beyond MVP. Clean Architecture brought a domain-first mindset, emphasizing separation of concerns between core logic and implementation details.
This phase wasn’t about the UI layer yet — it was about cleaning up the foundation so that UI frameworks (Activities, Fragments, or later ViewModels) didn’t control the business rules.
🧩 Domain Layer — Business Logic Rules the App
// Domain layer (pure Kotlin)
data class User(val id: String, val email: String)
interface AuthRepository {
fun login(email: String, password: String): Single<User>
}
class LoginUseCase(private val repo: AuthRepository) {
fun execute(email: String, pass: String) = repo.login(email, pass)
}
✅ Defines what the app does, not how. Completely framework-independent.
🌐 Data Layer — Implementation Details Live Here
class AuthRepositoryImpl(private val api: AuthApi) : AuthRepository {
override fun login(email: String, pass: String) =
api.login(LoginRequest(email, pass)).map { dto -> dto.toDomain() }
}
interface AuthApi {
@POST("auth/login")
fun login(@Body body: LoginRequest): Single<UserDto>
}
✅ Implements the domain contract using Retrofit, Room, etc. Easily replaceable.
💉 Dependency Injection (Dagger 2)
@Module
abstract class RepositoryModule {
@Binds abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}
✅ Dagger 2 connected the layers automatically — the first time we could swap implementations without touching business code.
🧠 Why It Mattered
Clean Architecture gave us:
- Framework independence: the domain layer no longer cared about Android.
- Testability: we could unit-test business logic in isolation.
- Scalability: teams could work in parallel on domain, data, and UI.
- Dependency Injection: Dagger 2 became the standard for wiring dependencies.
Phase 3 solved the backend and structural complexity of Android apps — paving the way for MVVM, which tackled UI and lifecycle complexity in the next phase.
🧩 Phase 4: MVVM & Android Architecture Components (2016–2019)
Google’s Architecture Components (ViewModel, LiveData, Room, etc.) changed the game.
UI logic finally had a lifecycle-aware home, and developers no longer lost state on rotation or leaked Activities through callbacks.
🧠 ViewModel — State Survives Configuration Changes
class LoginViewModel(
private val authRepo: AuthRepository
) : ViewModel() {
private val _state = MutableLiveData<LoginState>(LoginState.Idle)
val state: LiveData<LoginState> = _state
fun login(email: String, pass: String) = viewModelScope.launch {
_state.value = LoginState.Loading
runCatching { authRepo.login(email, pass) }
.onSuccess { _state.value = LoginState.Success(it) }
.onFailure { _state.value = LoginState.Error(it.message ?: "Oops") }
}
}
sealed class LoginState {
data object Idle : LoginState()
data object Loading : LoginState()
data class Success(val user: User) : LoginState()
data class Error(val msg: String) : LoginState()
}
✅ Simple, lifecycle-safe, coroutine-based, and reactive.
🎨 XML or Compose — Observing the State
// XML-era (2017)
viewModel.state.observe(this) { state ->
when (state) {
is LoginState.Loading -> showProgress()
is LoginState.Success -> showWelcome(state.user)
is LoginState.Error -> showError(state.msg)
else -> Unit
}
}
// Compose-era preview (2020+)
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.state.observeAsState(LoginState.Idle)
when (state) {
is LoginState.Loading -> CircularProgressIndicator()
is LoginState.Success -> Text("Welcome ${state.user.name}")
is LoginState.Error -> Text("Error: ${(state as LoginState.Error).msg}")
else -> Text("Enter credentials")
}
}
✅ UI reacts automatically when ViewModel emits a new state.
💡 Why It Mattered
- ViewModel replaced Presenters and handled configuration changes gracefully.
- LiveData (later Flow/StateFlow) made data reactive and lifecycle-aware.
- The Repository pattern solidified the data source abstraction.
- Dagger + ViewModelFactory became the new dependency-injection combo.
This phase made Android development sane again: fewer leaks, less boilerplate, and a clear one-way flow — the first real step toward unidirectional data flow and eventually MVI.
Phase 5: RxJava & the Reactive Shift (2017–2020)
Core idea
- Streams for everything (UI events, network, DB).
- Operators to transform/merge streams elegantly.
Pros
- Powerful composition, standardized async patterns.
Cons
- Steep learning curve, subscription management, memory leaks if mishandled.
Why it mattered
- Introduced unidirectional data flow patterns and pushed us toward MVI concepts.
⚙️ Phase 6: Coroutines & Flow (2019–2022)
By this time, Kotlin was officially embraced by Google — and with it came Coroutines and Flow.
Developers finally had a first-class, language-level way to write asynchronous code without pyramids of callbacks or endless Rx operators.
🚀 ViewModel + Coroutines — Straightforward Asynchronous Logic
class LoginViewModel(
private val authRepo: AuthRepository
) : ViewModel() {
var uiState by mutableStateOf<LoginUiState>(LoginUiState.Idle)
private set
fun login(email: String, pass: String) = viewModelScope.launch {
uiState = LoginUiState.Loading
try {
val user = authRepo.login(email, pass) // suspend function
uiState = LoginUiState.Success(user)
} catch (e: Exception) {
uiState = LoginUiState.Error(e.message ?: "Network error")
}
}
}
sealed class LoginUiState {
data object Idle : LoginUiState()
data object Loading : LoginUiState()
data class Success(val user: User) : LoginUiState()
data class Error(val msg: String) : LoginUiState()
}
✅ No callbacks, no Rx subscriptions, just sequential logic inside structured concurrency.
🌊 Flow — The Reactive Stream, Simplified
class UserRepository(
private val api: UserApi
) {
fun observeUser(id: String): Flow<User> = flow {
while (true) {
emit(api.getUser(id)) // emit latest user data
delay(10_000) // refresh every 10 s
}
}.flowOn(Dispatchers.IO)
}
viewModelScope.launch {
userRepo.observeUser("42").collect { user ->
uiState = LoginUiState.Success(user)
}
}
✅ Flows are cold streams: nothing happens until collected, and everything runs within the coroutine scope.
💡 Why It Mattered
- Structured concurrency gave developers predictable cancellation and error handling.
- Flows unified streams from database, network, and UI in a single model.
- Replaced RxJava in new projects with simpler, Kotlin-first syntax.
- Laid the foundation for MVI and StateFlow, where state updates flow in a single direction.
Coroutines and Flow bridged the gap between MVVM and modern MVI.
They made asynchronous state predictable, composable, and readable — the missing link before Compose made state the UI itself.
🎨 Phase 7: Compose & Unidirectional Data Flow Become Default (2020–2025)
With Jetpack Compose, Android finally moved to a declarative UI model — you no longer manually update the UI; instead, you describe what it should look like based on state.
This shift naturally enforced Unidirectional Data Flow (UDF):
Event → Logic → New State → Recompose UI.
🧠 ViewModel — Single Source of Truth
class LoginViewModel(
private val authRepo: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state = _state.asStateFlow()
fun onEmailChange(value: String) {
_state.update { it.copy(email = value) }
}
fun onLoginClick() = viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { authRepo.login(_state.value.email, _state.value.password) }
.onSuccess { user -> _state.update { it.copy(loading = false, user = user) } }
.onFailure { e -> _state.update { it.copy(loading = false, error = e.message) } }
}
}
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val user: User? = null,
val error: String? = null
)
✅ The ViewModel holds immutable state and emits it as a Flow — the UI only observes and renders.
🖥️ Compose — Declarative UI That Reacts to State
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("Email") }
)
Button(
onClick = viewModel::onLoginClick,
enabled = !state.loading
) {
if (state.loading) CircularProgressIndicator()
else Text("Login")
}
state.error?.let { Text(it, color = Color.Red) }
state.user?.let { Text("Welcome ${it.email}") }
}
}
✅ Compose rebuilds the UI automatically whenever state changes.
🔁 Unidirectional Data Flow (UDF)
flowchart LR UserAction --> ViewModel ViewModel --> Repository Repository --> ViewModel ViewModel --> UI UI --> UserAction
✅ All updates flow in one direction, eliminating race conditions and inconsistent state.
💡 Why It Mattered
- Declarative UI: Compose replaced imperative XML layouts.
- State-driven rendering: UI is a function of state, not a series of view updates.
- Flow + StateFlow: naturally integrated with Compose recompositions.
- UDF pattern: simplified complex UI logic and prepared the ground for MVI architectures.
With Compose, Android UI development became reactive by design — and MVI with a State Machine became the logical next step: predictable, testable, and visually declarative.
The Destination: MVI (Model-View-Intent) with a State Machine (2023–2025)
By 2023, MVI had become the natural evolution of everything before it — a pattern where UI events (Intents) drive predictable state transitions, and side effects are handled in isolation.
The result: fully deterministic UIs that are easier to test, reason about, and extend.
📘 I’ve covered this topic in detail — including code comparisons between Pure MVI, MVI with Reducer, and MVI with State Machine — in my previous article:
👉 Pure MVI, MVI with Reducer and MVI with State Machine
That post explains why the State Machine version is the “final form” — because it explicitly defines what each state can transition to, preventing illegal flows and making every user journey testable.
What is MVI?
- Model = immutable UI State.
- View = renders state and emits Intents (user/system events).
- Intent → Reducer → New State (no in-place mutation).
- Single source of truth (the store).
What’s the “State Machine” part?
- You formalize allowed states and transitions.
- Each event either validly transitions (S → S’) or is ignored/handled.
- You can draw it. You can table-test it. You can guarantee “this can’t happen.”
Minimal MVI Store (Reducer-style)
data class LoginState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null,
val user: User? = null
)
sealed interface LoginIntent {
data class EmailChanged(val value: String): LoginIntent
data class PasswordChanged(val value: String): LoginIntent
object Submit: LoginIntent
data class AuthSucceeded(val user: User): LoginIntent
data class AuthFailed(val message: String): LoginIntent
}
class LoginReducer(
private val authRepo: AuthRepository,
private val dispatch: (LoginIntent) -> Unit
) {
suspend fun reduce(state: LoginState, intent: LoginIntent): LoginState = when(intent) {
is LoginIntent.EmailChanged -> state.copy(email = intent.value, error = null)
is LoginIntent.PasswordChanged -> state.copy(password = intent.value, error = null)
LoginIntent.Submit -> {
// side-effect (async)
coroutineScope {
launch {
runCatching { authRepo.login(state.email, state.password) }
.onSuccess { dispatch(LoginIntent.AuthSucceeded(it)) }
.onFailure { dispatch(LoginIntent.AuthFailed(it.message ?: "Oops")) }
}
}
state.copy(loading = true, error = null)
}
is LoginIntent.AuthSucceeded -> state.copy(loading = false, user = intent.user)
is LoginIntent.AuthFailed -> state.copy(loading = false, error = intent.message)
}
}
Add an Explicit State Machine
Instead of one big LoginState, model exclusive screens/stages:
sealed interface LoginSmState {
data object Idle: LoginSmState
data class Editing(val email: String, val password: String, val error: String? = null): LoginSmState
data object Submitting: LoginSmState
data class Success(val user: User): LoginSmState
}
sealed interface LoginSmEvent {
data class EditEmail(val value: String): LoginSmEvent
data class EditPassword(val value: String): LoginSmEvent
data object Submit: LoginSmEvent
data class Succeeded(val user: User): LoginSmEvent
data class Failed(val message: String): LoginSmEvent
}
fun transition(from: LoginSmState, event: LoginSmEvent): LoginSmState = when (from) {
LoginSmState.Idle -> when (event) {
is LoginSmEvent.EditEmail -> LoginSmState.Editing(event.value, "", null)
is LoginSmEvent.EditPassword -> LoginSmState.Editing("", event.value, null)
else -> from
}
is LoginSmState.Editing -> when (event) {
is LoginSmEvent.EditEmail -> from.copy(email = event.value, error = null)
is LoginSmEvent.EditPassword -> from.copy(password = event.value, error = null)
LoginSmEvent.Submit -> LoginSmState.Submitting
else -> from
}
LoginSmState.Submitting -> when (event) {
is LoginSmEvent.Succeeded -> LoginSmState.Success(event.user)
is LoginSmEvent.Failed -> LoginSmState.Editing("", "", event.message)
else -> from
}
is LoginSmState.Success -> from // terminal
}
Why the state machine?
- Explicitness: impossible transitions are impossible to express.
- Predictability: bugs become illegal transitions you can spot in reviews.
- Testability: table-test every
(state, event) → newState.
Table-test example
data class Case(val from: LoginSmState, val event: LoginSmEvent, val to: LoginSmState)
val cases = listOf(
Case(LoginSmState.Idle, LoginSmEvent.EditEmail("a@b.com"), LoginSmState.Editing("a@b.com", "")),
Case(LoginSmState.Editing("a@b.com","x"), LoginSmEvent.Submit, LoginSmState.Submitting),
Case(LoginSmState.Submitting, LoginSmEvent.Succeeded(User("42")) , LoginSmState.Success(User("42")))
)
cases.forEach { (from, event, expected) ->
check(transition(from, event) == expected)
}
Compose + MVI + State Machine: Why It Clicks
- Compose re-renders from state → MVI gives you a single state.
- Complex flows (multi-step forms, verification, offline/online) become finite and reviewable.
- Side effects (network, DB, navigation) are contained and triggered by events, not view mutation.
Common Pitfalls (and Fixes)
- Hidden mutable state in ViewModel: always copy immutable state; avoid in-place mutation.
- Side effects inside render: keep effects in a dedicated layer (e.g., intent handlers/use cases).
- “God” state objects: split by feature or introduce explicit states (state machine).
- Navigation as side effect: emit
NavigationEventfrom the store; let the UI observe and navigate.
Job Offers
Final Take
- The last decade and a half taught us why architectures exist: predictability and change-friendliness.
- MVVM made Android reliable at scale; Compose made state the first-class citizen; MVI with a State Machine makes complex flows explicit and provable.
- If your app is simple, MVVM is fine. If it’s complex (banking, commerce, offline sync, multi-step), MVI + State Machine pays for itself — quickly.
Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee
This article was previously published on proandroiddev.com


