androidengineers.Book a session

Architecture Patterns

Patterns Tour: MVC → MVP → MVVM → MVI

article30 minMedium

Why Architecture Patterns Matter

Without deliberate architecture, Android apps devolve into "God Activities" — a single class responsible for UI rendering, business logic, data fetching, and lifecycle management simultaneously. Each pattern below is a response to the pain points of its predecessor.


MVC on Android

Model-View-Controller maps naturally to server-side web but awkwardly to Android. The Activity ends up playing both View and Controller roles, since it owns the layout and handles user events.

// Anti-pattern: Activity as MVC "Controller" AND "View"
class UserProfileActivity : AppCompatActivity() {

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .build()
        .create(UserApi::class.java)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_profile)

        // Controller logic lives here — tightly coupled to UI
        loadUser(userId = intent.getStringExtra("user_id") ?: return)
    }

    private fun loadUser(userId: String) {
        // Network call on main thread — just for illustration
        lifecycleScope.launch {
            try {
                val user = retrofit.getUser(userId)
                // Direct view manipulation
                findViewById<TextView>(R.id.tvName).text = user.name
                findViewById<TextView>(R.id.tvEmail).text = user.email
            } catch (e: Exception) {
                Toast.makeText(this@UserProfileActivity, "Error", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

MVC Pros & Cons

ProsCons
Familiar to web developersActivity = View + Controller (God class)
Simple for tiny screensUntestable without instrumented tests
No boilerplateBusiness logic entangled with lifecycle
Model changes require Activity to know update order

MVP (Model-View-Presenter)

MVP separates concerns by introducing a Presenter that holds business logic and communicates with the View through an interface. The Activity implements the View interface; the Presenter has no Android imports.

// Contract defines the boundary
interface UserProfileContract {
    interface View {
        fun showUser(name: String, email: String)
        fun showError(message: String)
        fun showLoading(show: Boolean)
    }

    interface Presenter {
        fun loadUser(userId: String)
        fun onDestroy()
    }
}

// Presenter: pure Kotlin, no Android imports
class UserProfilePresenter(
    private val view: UserProfileContract.View,
    private val userRepository: UserRepository,
    private val scope: CoroutineScope
) : UserProfileContract.Presenter {

    override fun loadUser(userId: String) {
        scope.launch {
            view.showLoading(true)
            userRepository.getUser(userId)
                .onSuccess { user ->
                    view.showLoading(false)
                    view.showUser(user.name, user.email)
                }
                .onFailure { error ->
                    view.showLoading(false)
                    view.showError(error.message ?: "Unknown error")
                }
        }
    }

    override fun onDestroy() {
        scope.cancel()
    }
}

// Activity is a thin View
class UserProfileActivity : AppCompatActivity(), UserProfileContract.View {

    private lateinit var presenter: UserProfilePresenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_profile)
        presenter = UserProfilePresenter(this, UserRepository(), lifecycleScope)
        presenter.loadUser(intent.getStringExtra("user_id") ?: return)
    }

    override fun showUser(name: String, email: String) {
        binding.tvName.text = name
        binding.tvEmail.text = email
    }

    override fun showError(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    override fun showLoading(show: Boolean) {
        binding.progressBar.isVisible = show
    }

    override fun onDestroy() {
        super.onDestroy()
        presenter.onDestroy()
    }
}

MVP Pros & Cons

ProsCons
Presenter is unit testableManual lifecycle management
Clear separation of concernsView interface can become large
Activity is thinMemory leaks if scope not cancelled
Good for legacy codebasesOne-to-one View/Presenter coupling

MVVM (Model-View-ViewModel)

MVVM replaces the Presenter with a ViewModel that survives configuration changes. The View observes state rather than receiving imperative calls. StateFlow or LiveData carries state from ViewModel to UI.

// UI state as a sealed class
data class UserProfileUiState(
    val isLoading: Boolean = false,
    val name: String = "",
    val email: String = "",
    val error: String? = null
)

// ViewModel: survives rotation, no View reference
@HiltViewModel
class UserProfileViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["user_id"])

    private val _uiState = MutableStateFlow(UserProfileUiState())
    val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()

    init {
        loadUser()
    }

    private fun loadUser() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            getUserUseCase(userId)
                .onSuccess { user ->
                    _uiState.update { it.copy(isLoading = false, name = user.name, email = user.email) }
                }
                .onFailure { error ->
                    _uiState.update { it.copy(isLoading = false, error = error.message) }
                }
        }
    }
}

// Fragment observes state reactively
@AndroidEntryPoint
class UserProfileFragment : Fragment(R.layout.fragment_user_profile) {

    private val viewModel: UserProfileViewModel by viewModels()
    private lateinit var binding: FragmentUserProfileBinding

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    binding.progressBar.isVisible = state.isLoading
                    binding.tvName.text = state.name
                    binding.tvEmail.text = state.email
                    state.error?.let { Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show() }
                }
            }
        }
    }
}

MVVM Pros & Cons

ProsCons
ViewModel survives rotationState can drift if not modeled carefully
Lifecycle-aware with repeatOnLifecycleData binding XML can hide logic
Jetpack-native (Google recommended)Two-way binding can cause infinite loops
Easy unit testing with TestCoroutineDispatcherEvent handling needs extra care (Channel/SharedFlow)

MVI (Model-View-Intent)

MVI enforces strict unidirectional data flow: the View sends Actions/Intents, a Reducer produces a new immutable State, and the View renders that state. Side effects are handled separately.

// Immutable state
data class UserProfileState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val error: String? = null
)

// All possible user actions
sealed interface UserProfileAction {
    data class LoadUser(val userId: String) : UserProfileAction
    object Retry : UserProfileAction
}

// Side effects (one-shot events)
sealed interface UserProfileEffect {
    data class ShowSnackbar(val message: String) : UserProfileEffect
    object NavigateBack : UserProfileEffect
}

@HiltViewModel
class UserProfileViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(UserProfileState())
    val state: StateFlow<UserProfileState> = _state.asStateFlow()

    private val _effect = Channel<UserProfileEffect>(Channel.BUFFERED)
    val effect = _effect.receiveAsFlow()

    fun onAction(action: UserProfileAction) {
        when (action) {
            is UserProfileAction.LoadUser -> loadUser(action.userId)
            UserProfileAction.Retry -> {
                val userId = _state.value.user?.id ?: return
                loadUser(userId)
            }
        }
    }

    // Pure reducer-style update
    private fun loadUser(userId: String) {
        viewModelScope.launch {
            // Reduce: loading state
            _state.update { reduce(it, LoadingStarted) }

            getUserUseCase(userId)
                .onSuccess { user ->
                    // Reduce: success state
                    _state.update { reduce(it, UserLoaded(user)) }
                }
                .onFailure { error ->
                    // Reduce: error state
                    _state.update { reduce(it, LoadingFailed(error.message)) }
                    // Side effect
                    _effect.send(UserProfileEffect.ShowSnackbar("Failed to load user"))
                }
        }
    }

    // Explicit reducer function — state transitions are explicit and testable
    private fun reduce(state: UserProfileState, event: UserEvent): UserProfileState = when (event) {
        is LoadingStarted -> state.copy(isLoading = true, error = null)
        is UserLoaded -> state.copy(isLoading = false, user = event.user)
        is LoadingFailed -> state.copy(isLoading = false, error = event.message)
    }
}

// Fragment sends actions, observes state and effects
@AndroidEntryPoint
class UserProfileFragment : Fragment(R.layout.fragment_user_profile) {

    private val viewModel: UserProfileViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch { viewModel.state.collect { render(it) } }
                launch {
                    viewModel.effect.collect { effect ->
                        when (effect) {
                            is UserProfileEffect.ShowSnackbar ->
                                Snackbar.make(view, effect.message, Snackbar.LENGTH_SHORT).show()
                            UserProfileEffect.NavigateBack -> findNavController().popBackStack()
                        }
                    }
                }
            }
        }

        binding.btnRetry.setOnClickListener {
            viewModel.onAction(UserProfileAction.Retry)
        }
    }

    private fun render(state: UserProfileState) {
        binding.progressBar.isVisible = state.isLoading
        binding.tvName.text = state.user?.name ?: ""
        binding.tvEmail.text = state.user?.email ?: ""
        binding.errorGroup.isVisible = state.error != null
    }
}

MVI Pros & Cons

ProsCons
Predictable: single source of truthMore boilerplate than MVVM
State is immutable — no partial updatesOverkill for simple screens
Reducer is a pure function — trivial to unit testLearning curve for teams new to functional style
Time-travel debugging possibleAction/State explosion in complex screens

When to Choose Which

PatternBest For
MVCLegacy code you're not refactoring yet
MVPTeams migrating away from MVC, Java-heavy codebases
MVVMMost modern Android apps — Google's recommended approach
MVIComplex screens with many interactions, need audit trail, Compose

Key Takeaways

ConceptSummary
MVC on AndroidActivity inevitably becomes God class
MVPTestable Presenter via View interface; manual lifecycle
MVVMViewModel + StateFlow; lifecycle-aware; Jetpack-native
MVIActions → Reducer → Immutable State; side effects via Channel
ChoosingMatch complexity; don't use MVI for a settings screen

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Patterns Tour: MVC → MVP → MVVM → MVI | Android System Design | Android Engineers