Android Architecture Timeline: MVC, MVP, MVVM, MVI & Clean Architecture
Understanding the 'why' behind architectural choices — what every senior interview asks
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Android architecture evolved from untestable Activity-driven code (MVC) through the testable but boilerplate-heavy MVP, to the current industry standard MVVM with Clean Architecture. MVI is gaining traction in Compose-based apps. Senior developers must not only know the patterns but also explain WHY each pattern emerged, what problem it solves, and when to apply each layer.
Real-world relevance
A field operations enterprise app uses Clean Architecture: Domain layer has WorkOrderUseCase classes (pure Kotlin, tested with JUnit). Data layer has WorkOrderRepositoryImpl using Room (offline-first) and Retrofit (network). Presentation layer has WorkOrderViewModel (Hilt-injected) exposing StateFlow. The app is split into :feature:work-orders, :feature:assets, :core:data, :core:domain modules.
Key points
- MVC — the beginning — Model-View-Controller: Controller handles user input and updates both Model and View. In Android's early days, Activity was both Controller AND View — causing 'Massive View Controller' syndrome. Logic was untestable because it was tied to Android framework classes that required running on a device.
- MVP — testability enters — Model-View-Presenter: Presenter contains business logic and communicates with View through an interface. View (Activity/Fragment) is dumb — just calls Presenter methods and implements View interface. Presenter has no Android dependencies — fully unit testable. Problem: lots of interface boilerplate, memory leaks from View reference in Presenter.
- MVVM — the current standard — Model-View-ViewModel: ViewModel holds UI state and business logic, exposes observable streams (StateFlow/LiveData). View observes and reacts. ViewModel has NO reference to the View — solves memory leaks. Configuration changes safe because ViewModel survives rotation. The officially recommended architecture since 2017.
- ViewModel survives rotation — Android destroys and recreates Activity on rotation. ViewModel is stored in ViewModelStore which survives this — the same ViewModel instance is returned. This is the core problem MVVM solved: no more re-fetching data or saving state to Bundle for rotation.
- Clean Architecture layers — Presentation (UI + ViewModel) → Domain (UseCases + Entities — pure Kotlin, no Android) → Data (Repositories + DataSources + API/DB). Dependency rule: outer layers depend on inner layers. The domain layer has ZERO Android dependencies — fully testable with JUnit.
- UseCase / Interactor pattern — class GetDocumentsUseCase(private val repo: DocumentRepository) { operator fun invoke(): Flow> } — a single operation in the domain layer. Decouples ViewModel from repository details. Makes business rules testable in isolation. Controversial: some teams skip UseCases for simple CRUD apps.
- MVI — for Compose era — Model-View-Intent: View emits user Intents (sealed class), ViewModel reduces them to produce a new State (sealed class), View renders the State. Unidirectional data flow — state changes only happen in one place (the reducer). Excellent for Compose where the whole screen re-renders on state change.
- MVI vs MVVM key difference — MVVM: ViewModel has multiple observable streams for different parts of state. MVI: ViewModel has ONE state stream and an intent handler — each intent produces a new state via a pure function (reducer). MVI is more strict, MVVM is more pragmatic. Industry standard is MVVM with UiState sealed class (which borrows MVI ideas).
- Why architecture matters in interviews — Interviewers ask architecture questions to assess: Can you separate concerns? Can you write testable code? Do you understand the tradeoffs? Have you worked on a real team codebase? Wrong answers: 'I put all code in Activity' or 'I use whatever the tutorial showed'.
- Repository pattern — Repository abstracts data sources — the ViewModel/UseCase doesn't know if data comes from network, cache, or database. Single source of truth principle: Room is the single source; API data is fetched and stored in Room; UI observes Room. Offline-first apps rely on this pattern.
- Dependency Injection and architecture — Hilt (or Koin) wires the dependency graph: ViewModel is injected with UseCases, UseCases are injected with Repositories, Repositories are injected with Dao and ApiService. DI makes each layer independently testable with fake/mock dependencies.
- Multi-module architecture — Large enterprise apps split by feature modules (:feature:documents, :feature:users) or by layer (:data, :domain, :presentation). Improves build times, enforces architectural boundaries (data module cannot import presentation module), enables independent team ownership.
Code example
// Clean Architecture layers — field operations app
// DOMAIN LAYER — pure Kotlin, zero Android imports
data class WorkOrder(val id: String, val title: String, val status: Status)
interface WorkOrderRepository {
fun getWorkOrders(): Flow<List<WorkOrder>>
suspend fun sync(): Result<Unit>
}
class GetWorkOrdersUseCase(private val repository: WorkOrderRepository) {
operator fun invoke(): Flow<List<WorkOrder>> =
repository.getWorkOrders()
.map { orders -> orders.sortedBy { it.status } }
}
// DATA LAYER — implements domain interfaces
class WorkOrderRepositoryImpl(
private val api: WorkOrderApi,
private val dao: WorkOrderDao
) : WorkOrderRepository {
// Single source of truth: Room is the source; API populates Room
override fun getWorkOrders(): Flow<List<WorkOrder>> =
dao.getAll()
.map { entities -> entities.map { it.toDomain() } }
.flowOn(Dispatchers.IO)
override suspend fun sync(): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val remote = api.fetchWorkOrders()
dao.insertAll(remote.map { it.toEntity() })
}
}
}
// PRESENTATION LAYER — ViewModel with Hilt injection
@HiltViewModel
class WorkOrderViewModel @Inject constructor(
private val getWorkOrders: GetWorkOrdersUseCase,
private val syncOrders: SyncWorkOrdersUseCase
) : ViewModel() {
val uiState: StateFlow<UiState<List<WorkOrder>>> =
getWorkOrders()
.map<List<WorkOrder>, UiState<List<WorkOrder>>> { UiState.Success(it) }
.catch { emit(UiState.Error(it.message ?: "Error")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
fun sync() {
viewModelScope.launch {
syncOrders().onFailure { error ->
// emit error event via SharedFlow
}
}
}
}
// MVI pattern — for Compose era
data class WorkOrderScreenState(
val orders: List<WorkOrder> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
sealed class WorkOrderIntent {
object LoadOrders : WorkOrderIntent()
data class FilterByStatus(val status: Status) : WorkOrderIntent()
data class SelectOrder(val id: String) : WorkOrderIntent()
}Line-by-line walkthrough
- 1. interface WorkOrderRepository in domain layer — no implementation details; domain only knows WHAT, not HOW
- 2. class GetWorkOrdersUseCase(private val repository: WorkOrderRepository) — zero Android imports; injectable; testable with fake repo
- 3. operator fun invoke() — allows calling the use case as a function: getWorkOrders() instead of getWorkOrders.execute()
- 4. class WorkOrderRepositoryImpl implements domain interface — data layer fulfills domain contract; domain never imports this class
- 5. dao.getAll().map { entities -> entities.map { toDomain() } } — entity-to-domain mapping in data layer; domain models are pure
- 6. @HiltViewModel class WorkOrderViewModel @Inject constructor — Hilt manages the dependency graph; ViewModel receives UseCases
- 7. getWorkOrders().map { UiState.Success(it) }.catch { UiState.Error }.stateIn — cold Flow to hot StateFlow pipeline
- 8. data class WorkOrderScreenState — MVI single state; entire screen state in one place
- 9. sealed class WorkOrderIntent — all user actions enumerated; ViewModel processes them in a when expression
Spot the bug
// In the domain layer:
import android.content.Context // bug 1
class GetUserUseCase(
private val repo: UserRepository,
private val context: Context // bug 1
) {
fun execute(): List<User> { // bug 2
return repo.getUsers()
}
}
// In ViewModel:
class UserViewModel : ViewModel() {
val users = MutableLiveData<List<User>>() // bug 3
fun load() = viewModelScope.launch {
users.value = repo.getUsers() // bug 3
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Guide to App Architecture (developer.android.com)
- UI Layer Architecture (developer.android.com)
- Domain Layer (developer.android.com)
- Data Layer (developer.android.com)
- Hilt Dependency Injection (developer.android.com)