Lesson 11 of 83 intermediate

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

Architecture is like the layout of a restaurant. MVC: the chef cooks AND takes orders AND does dishes (tangled). MVP: the waiter (Presenter) takes orders and carries food, the chef (Model) cooks, the dining room (View) just presents. MVVM: the kitchen posts a menu board (ViewModel) that updates automatically, guests just watch. MVI: the kitchen follows a strict recipe (single state update), no improvisation.

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

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. 1. interface WorkOrderRepository in domain layer — no implementation details; domain only knows WHAT, not HOW
  2. 2. class GetWorkOrdersUseCase(private val repository: WorkOrderRepository) — zero Android imports; injectable; testable with fake repo
  3. 3. operator fun invoke() — allows calling the use case as a function: getWorkOrders() instead of getWorkOrders.execute()
  4. 4. class WorkOrderRepositoryImpl implements domain interface — data layer fulfills domain contract; domain never imports this class
  5. 5. dao.getAll().map { entities -> entities.map { toDomain() } } — entity-to-domain mapping in data layer; domain models are pure
  6. 6. @HiltViewModel class WorkOrderViewModel @Inject constructor — Hilt manages the dependency graph; ViewModel receives UseCases
  7. 7. getWorkOrders().map { UiState.Success(it) }.catch { UiState.Error }.stateIn — cold Flow to hot StateFlow pipeline
  8. 8. data class WorkOrderScreenState — MVI single state; entire screen state in one place
  9. 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?
Domain layer must not import Android. UseCase should return Flow. LiveData.value must be set on main thread.
Show answer
Bug 1: android.content.Context in the domain layer violates Clean Architecture — domain must have zero Android dependencies. Remove Context from the use case; pass any needed configuration as plain Kotlin types. Bug 2: execute() returns List<User> synchronously — should return Flow<List<User>> or suspend fun execute(): List<User> for async operation. Bug 3: users.value = (setting LiveData) from a coroutine launched with Dispatchers.IO would crash — LiveData.value must be set on main thread. Fix: use postValue() or use StateFlow instead, or ensure the coroutine runs on Dispatchers.Main.

Explain like I'm 5

Imagine your school has: a Library (data — stores everything), a Teacher (domain — knows the rules and makes decisions), and a Classroom (presentation — shows things to students). MVC: the teacher does everything including arranging desks. MVP: a teacher's assistant (presenter) takes instructions. MVVM: the classroom has a live scoreboard (ViewModel) that updates automatically. Clean Architecture says: the teacher should never need to talk directly to the library — there's a librarian (repository) in between.

Fun fact

Google's Android team published the official 'Guide to App Architecture' in 2018. By 2022, they added explicit guidance for MVI-style unidirectional data flow with a single UiState class — acknowledging that pure MVVM with multiple observable fields was causing state consistency bugs in complex screens. The community had discovered MVI problems before the official guidance caught up.

Hands-on challenge

Design the full architecture for a 'Field Technician Work Orders' screen. Specify: 1) Domain layer: WorkOrder entity, WorkOrderRepository interface, GetWorkOrdersUseCase, FilterWorkOrdersUseCase. 2) Data layer: WorkOrderRepositoryImpl with Room + Retrofit, offline-first sync strategy. 3) Presentation layer: WorkOrderViewModel with Hilt, UiState sealed class, MVI-style intent handling (sealed class WorkOrderIntent). 4) What module structure would you use for a 5-developer team? 5) How would you test each layer?

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery