MVC, MVP, MVVM, and MVI: They Didn’t Replace Each Other. They Evolved to Solve Different Problems.
Compare MVC, MVP, MVVM, and MVI by the problems they solve, the costs they add, and the UI complexity that justifies each pattern.
2026-09-24 12:05:58 - Mohamad Abuzaid
MVC, MVP, MVVM, and MVI are often taught as generations of the same product.
MVC came first. MVP cleaned it up. MVVM modernized it. MVI finally made state predictable. Pick the newest acronym and your architecture is up to date.
That story is convenient. It is also a poor way to make an engineering decision.
These patterns did not arrive as software upgrades that made the previous version obsolete. They emphasize different boundaries because UI development kept exposing different problems: domain rules mixed with widgets, presentation logic that was expensive to test, state spread across callbacks, and asynchronous work that made a screen difficult to reason about.
Each pattern protects something useful. Each can also become ceremony, indirection, or a new place to hide the same mess.
The practical question is not, “Which pattern won?” It is:
What is making this screen difficult to change, test, or understand—and which boundary would actually reduce that difficulty?
The common problem before the pattern name
Imagine a checkout screen.
At first, the screen seems simple. It displays a total and has a Pay button. Then the real requirements arrive:
- disable the button while a payment is running;
- show validation errors before making a request;
- handle authentication challenges;
- retry safely after a network failure;
- prevent a second charge after a repeated tap;
- restore meaningful state after a lifecycle event;
- navigate after success;
- record analytics without coupling them to rendering;
- test all of this without launching the complete UI.
Without a clear boundary, the screen becomes the meeting place for everything: widgets, navigation, networking, business rules, loading flags, error messages, callbacks, and lifecycle code.
The first architectural pressure is separation. Later pressures are ownership, synchronization, predictability, and cost of testing.
That distinction matters because “separation of concerns” is too vague to select a pattern. A codebase can contain five layers and still leave three objects competing to own the same loading state.
A more useful diagnosis asks four questions:
- Who owns the state displayed on the screen?
- What is allowed to change that state?
- Where do network, database, navigation, and analytics effects run?
- Can a test exercise the decision without driving the UI framework?
MVC, MVP, MVVM, and MVI answer those questions differently.
MVC: separate the domain from the interface
MVC’s lasting contribution is not a three-box diagram. It is the separation between the domain and the presentation.
The Model represents domain data and behavior. The View displays information. The Controller interprets input and coordinates what happens next.
That sounds obvious now because the idea succeeded. It was not always obvious that domain objects should remain useful without knowing which window, screen, or control happens to display them.
Martin Fowler’s discussion of GUI architectures is worth reading precisely because it resists the simplified textbook version. “MVC” has referred to several materially different designs over time. Classic Smalltalk MVC, web MVC, and an Android Activity described as a controller do not have identical collaboration rules.
Still, the pressure it addresses is clear: the business model should not be buried inside UI code.
For a small checkout screen, an MVC-style controller can be perfectly reasonable:
Tap Pay ↓ Controller validates the request ↓ Model performs the payment decision ↓ View renders the result
The trouble begins when “Controller” becomes the name for everything that does not fit elsewhere.
If it validates the form, starts requests, maps errors, formats currency, changes widgets, decides navigation, owns retry state, and records analytics, the code is technically separated into Model, View, and Controller while the controller remains a monolith.
MVC improved the first boundary. It did not guarantee that presentation coordination would stay small.
Use it when the interaction is straightforward, the framework already gives you a natural controller boundary, and the state does not need an elaborate synchronization mechanism. Do not reject that simplicity merely because another acronym is newer.
My earlier Architecture Patterns overview explains the traditional roles with Android/Kotlin examples. This article focuses on the pressure behind each boundary and the point at which that boundary stops being enough.
MVP: make presentation decisions testable
MVP responds to a practical testing problem: what if the View is too closely tied to the UI framework to test presentation behavior cheaply?
The Presenter receives user actions, talks to the Model, and tells a View interface what to display. The concrete screen implements that interface.
interface CheckoutView {
fun showLoading()
fun showPaymentError(message: String)
fun showReceipt(receiptId: String)
}
sealed interface ChargeResult {
data class Succeeded(val receiptId: String) : ChargeResult
data class Failed(val message: String) : ChargeResult
}
interface PaymentService {
suspend fun charge(orderId: String): ChargeResult
}
class CheckoutPresenter(
private val view: CheckoutView,
private val payments: PaymentService,
) {
private var isPaying = false
suspend fun onPayClicked(orderId: String) {
if (isPaying) return
isPaying = true
view.showLoading()
try {
when (val result = payments.charge(orderId)) {
is ChargeResult.Succeeded -> view.showReceipt(result.receiptId)
is ChargeResult.Failed -> view.showPaymentError(result.message)
}
} finally {
isPaying = false
}
}
}
The example is intentionally small. A Presenter test can provide a fake CheckoutView, invoke onPayClicked, and verify the visible decisions without starting an Activity, rendering a composable, or driving a browser.
That was a meaningful improvement for imperative UI frameworks. Presentation behavior had an explicit home, and the View could become passive.
The cost is equally visible.
As a screen grows, the View interface can become a remote control with dozens of commands:
showLoading() hideLoading() showError() hideError() enablePayButton() disablePayButton() showAuthenticationChallenge() navigateToReceipt()
Now correctness depends on the order of commands. If showError() is called without hideLoading(), the screen may display two incompatible conditions. Reattaching a View after a lifecycle change can require replaying enough commands to reconstruct what the screen should look like.
MVP makes presentation decisions testable, but a command-oriented View does not automatically give the screen one durable description of its state.
Use MVP when you have an imperative View, need to isolate presentation behavior from a framework, and can keep the View contract focused. Its boilerplate is not irrational if it buys cheap tests around expensive UI code. It becomes painful when the interface mirrors every widget and the Presenter starts manually synchronizing a complex screen.
MVVM: let the screen render observable state
MVVM changes the conversation from “Which commands should I send to the View?” to “What state should the View observe?”
The ViewModel exposes presentation-ready data and actions. The View observes that data and renders it. That fits declarative and reactive UI frameworks naturally because rendering is already expressed as a function of state.
For the checkout screen, the ViewModel might expose one model:
data class CheckoutUiState(
val total: String,
val isPaying: Boolean = false,
val errorMessage: String? = null,
val receiptId: String? = null,
)
The screen no longer needs a remembered sequence of show and hide commands. It can render the current snapshot:
CheckoutScreen(
state = state,
onPay = viewModel::pay,
)
This is why MVVM feels comfortable in Jetpack Compose, SwiftUI, React, and other state-driven systems. The View consumes observable state, and the ViewModel translates application data into a shape the screen can render.
Fowler described the related Presentation Model as pulling presentation state and behavior out of GUI controls. Current Android guidance applies the same pressure in platform terms: a screen-level state holder produces UI state, the UI sends events back, and a ViewModel is the recommended Android implementation when that state holder needs access to the data layer. Android also recommends keeping state near the lowest owner that genuinely needs it rather than moving every local UI detail into a screen ViewModel. See the official guidance on the UI layer and state hoisting.
One naming trap is worth making explicit:
Using Android’s ViewModel class does not prove that an application has a coherent MVVM architecture.
ViewModel is a lifecycle-aware platform type. MVVM is a presentation pattern. You can put networking, navigation, formatting, permissions, analytics, caching, and half the domain into one Android ViewModel and still have a god object with an excellent lifecycle.
MVVM also leaves design choices open:
- Is there one state object or many observable fields?
- Who serializes simultaneous updates?
- Are navigation and messages state, events, or effects?
- What happens when two requests finish out of order?
- Does the ViewModel contain business rules or delegate to the domain?
Those are not reasons to abandon MVVM. They are reasons to stop treating the label as a complete architecture.
Use MVVM when the UI is naturally state-driven, the screen benefits from observable presentation state, and the team can keep the ViewModel focused on state production and user actions. A small screen may need only a plain state holder. A complex screen may need stronger rules for transitions and effects.
MVI: make transitions explicit when async state becomes hard to follow
MVI is useful when the difficult part is no longer observing state. The difficult part is understanding how that state is allowed to change.
The exact meaning of MVI varies across libraries and teams. In practical Android discussions, it often describes a unidirectional loop:
Intent or action → state holder → new state → rendered UI
↓
side effect
↓
result becomes an event
The important properties are more useful than the acronym:
- the UI sends actions rather than mutating shared state directly;
- the screen has an explicit state model;
- transitions are centralized and preferably pure;
- network, database, navigation, and analytics work is treated as a side effect;
- the result of an effect returns through a defined path.
Here is a deliberately small reducer for the checkout flow:
data class CheckoutState(
val isPaying: Boolean = false,
val errorMessage: String? = null,
val receiptId: String? = null,
)
sealed interface CheckoutEvent {
data object PayClicked : CheckoutEvent
data class PaymentSucceeded(val receiptId: String) : CheckoutEvent
data class PaymentFailed(val message: String) : CheckoutEvent
}
sealed interface CheckoutEffect {
data object ChargePayment : CheckoutEffect
}
data class Next(
val state: CheckoutState,
val effects: List<CheckoutEffect> = emptyList(),
)
fun reduce(state: CheckoutState, event: CheckoutEvent): Next = when (event) {
CheckoutEvent.PayClicked -> {
if (state.isPaying || state.receiptId != null) {
Next(state)
} else {
Next(
state = state.copy(isPaying = true, errorMessage = null),
effects = listOf(CheckoutEffect.ChargePayment),
)
}
}
is CheckoutEvent.PaymentSucceeded -> Next(
state = state.copy(
isPaying = false,
receiptId = event.receiptId,
),
)
is CheckoutEvent.PaymentFailed -> Next(
state = state.copy(
isPaying = false,
errorMessage = event.message,
),
)
}
The reducer does not charge a card. It describes what the screen becomes and which effect must run. An effect handler performs ChargePayment; its success or failure returns as another event.
This remains a teaching example, not a complete payment protocol. If attempts can overlap or survive process boundaries, events and effects also need stable operation identifiers and the payment boundary needs server-side idempotency.
That indirection has a cost, but it gives you something valuable: a test can feed an old state and an event into reduce, then assert the exact new state and requested effects. A developer debugging the screen has a limited set of transitions to inspect.
This becomes helpful when a screen has overlapping sources of change: optimistic updates, retries, pagination, background synchronization, cancellation, push updates, and lifecycle restoration. Android’s current architecture guidance strongly recommends unidirectional data flow, with state flowing to the UI and events flowing back to the state holder. That guidance overlaps with many MVI implementations without requiring every application to adopt an MVI library or a single global reducer.
MVI can also become architecture theatre.
A two-field settings screen does not automatically improve when one toggle travels through Intent, Action, Result, Reducer, Effect, and State. More types can make a transition explicit, but they can also make a simple change harder to locate. A giant state object may re-render unrelated UI. A global event bus can hide ownership. A reducer can look pure while an effect handler becomes the new god object.
Use an MVI-style loop when state transitions and concurrent effects are genuinely difficult to trace. Do not adopt it merely to make a small screen look consistent with the most complex screen in the application.
They overlap more than architecture diagrams admit
Real applications rarely fit one pure label.
An Android screen may use a ViewModel, immutable StateFlow, events, and a reducer. Is that MVVM because it has a ViewModel, or MVI because it uses unidirectional transitions? The more useful answer is to describe what the code actually guarantees.
For example:
The screen renders one immutable state. User actions go to a screen-level state holder. Pure transitions are reduced synchronously. Network and navigation effects are handled separately. Domain rules live outside the presentation layer.
That sentence tells a teammate far more than “we use MVI.”
Pattern names are shortcuts for discussing constraints. They should not replace the discussion.
This also matters in Kotlin Multiplatform. You can share a state holder and domain rules while keeping Compose and SwiftUI views native. Or you can keep platform-specific presentation models because lifecycle and navigation differ. My offline-first Kotlin Multiplatform example uses a shared ViewModel because that boundary fits the feature; it does not argue that every UI concern belongs in shared code.
What all four patterns are trying to protect
Under the different vocabulary, these patterns repeatedly defend the same engineering qualities.
Domain decisions should survive a change of UI
Payment rules, validation, pricing, and permissions should not become inaccessible because they were written inside a button callback.
State needs an owner
If the View, Presenter, ViewModel, repository, and navigation layer can all mutate the same concept, no pattern diagram will make the result predictable.
Changes should have a traceable path
A developer should be able to follow a user action to a decision, an effect, a result, and the next visible state.
Side effects need a boundary
Network calls, database writes, navigation, analytics, timers, and system APIs behave differently from pure state transformations. Hiding them inside a reducer or rendering function does not make them pure.
Important decisions should be cheap to test
The goal is not to mock every class. The goal is to verify validation, transitions, retries, and failure behavior without paying the full cost of the UI framework for every case.
These qualities matter more than whether your folders are named controller, presenter, viewmodel, or store.
Why the newest pattern is not automatically the best one
Architecture has carrying costs.
Every interface must be understood. Every state wrapper must be updated. Every event type creates another step between a user action and the code that handles it. Every abstraction can protect a boundary, but it can also make the system harder to navigate.
The right amount of architecture depends on the cost of the problem it prevents.
If one developer can understand a screen in five minutes, reproduce its failures, and test its decisions, adding six layers may reduce clarity rather than improve it. If a payment flow has nine asynchronous states and three external systems, relying on scattered booleans and callbacks is false simplicity.
Team context matters too:
- A familiar pattern can reduce onboarding time.
- A novel internal framework can make every feature dependent on its author.
- Consistency helps until it forces trivial screens to carry enterprise ceremony.
- A library can standardize mechanics, but it cannot decide where business rules belong.
Do not redesign the entire application because one screen became complicated. Introduce the boundary where the pressure exists, keep the change reviewable, and prove that it improves the code you need to maintain. The same incremental principle is behind small pull requests as a technical strategy.
A practical selection guide
Start with the screen, not the acronym.
- Domain logic is mixed into UI handlers. Start with MVC-style separated presentation. Watch for controllers becoming a dumping ground.
- Presentation decisions are expensive to test through the framework. Start with MVP and a focused View contract. Watch for command ordering and large View interfaces.
- The UI should render observable presentation state. Start with MVVM or a screen-level state holder. Watch for god ViewModels and fragmented observable fields.
- Async results and state transitions are difficult to trace. Start with MVI-style unidirectional flow and reducers. Watch for ceremony, giant state objects, and effect-handler monoliths.
Then ask these questions:
- How many meaningful states can the screen enter? A form with enabled and disabled may not need a reducer. A checkout with idle, validating, authenticating, paying, retryable failure, terminal failure, and success probably needs an explicit state model.
- How many sources can change the screen? User input alone is different from user input plus database updates, network results, background sync, and push events.
- Can effects overlap or finish out of order? If yes, define cancellation, serialization, deduplication, and stale-result behavior explicitly.
- What must survive lifecycle or process recreation? Do not confuse an in-memory state holder with durable state.
- Which decisions deserve fast tests? Put those decisions behind a boundary that does not require the full UI runtime.
- Can the team trace one action end to end? If not, more abstraction may worsen the problem unless it creates a clearer path.
My default recommendation is simple:
- Keep local UI state local.
- Move business rules out of the View.
- Give screen state one clear owner.
- Expose immutable state when the UI is state-driven.
- Make side effects visible.
- Add explicit events and reducers when transitions—not fashion—justify them.
Final thoughts
MVC, MVP, MVVM, and MVI are not levels in an architecture game.
They are different responses to recurring UI problems. MVC protects the domain from the interface. MVP creates a testable presentation boundary. MVVM gives state-driven views an observable presentation model. MVI makes transitions and effects explicit when asynchronous behavior becomes difficult to follow.
None of them prevents poor boundaries. None of them removes the need to decide who owns state, where effects run, what survives lifecycle changes, or how the important behavior is tested.
Choose the smallest pattern that makes the current complexity easier to reason about. If the pressure changes, evolve the boundary deliberately.
Which problem is your current UI architecture actually solving—and which problem is it quietly creating?