Skip to main content

MVC, MVP, MVVM, MVI - Architectural Patterns

TL;DR

MVC (Model-View-Controller): Model holds data, View renders UI, Controller handles input. Bidirectional communication, easy to start, hard to reason about at scale.

MVP (Model-View-Presenter): Presenter (logic) separates View (passive) from Model. Better testability, more boilerplate. View is dumb, Presenter is testable.

MVVM (Model-View-ViewModel): ViewModel exposes data/commands to View via two-way data binding. Reduces boilerplate, popular in Angular/WPF. Magic binding can hide bugs.

MVI (Model-View-Intent): Unidirectional flow: User intent → Model update → View render. Most predictable, steepest learning curve, best for complex apps.

Learning Objectives

You will be able to:

  • Compare patterns by separation of concerns, testability, and data flow complexity.
  • Choose appropriate pattern based on framework (React, Angular, Vue) and app size.
  • Understand bidirectional vs unidirectional data binding trade-offs.
  • Design testable, maintainable component architectures.
  • Recognize anti-patterns and refactor toward better separation.

Motivating Scenario

You're building a form with user validation, async API calls, and error handling. Different patterns lead to different code structures and maintainability:

MVC approach: User types → Controller updates Model → Model notifies View → View renders. Easy to start, but circular dependencies between Model-View-Controller make changes dangerous.

MVVM approach: User types in View → ViewModel validates + updates → View re-renders via data binding. Less code, but magic binding makes debugging harder.

MVI approach: User types → Intent emitted → Model (reducer) computes new state → View renders. Every change traceable, but more boilerplate.

Core Patterns Explained

MVC (Model-View-Controller)

Separates application into three interconnected layers:

Model (data) ↔↔ Controller (logic) ↔↔ View (UI)
↓ (updates) ↓ (input) ↓ (renders)

Flow:

  1. User interacts with View
  2. Controller receives input, updates Model
  3. Model notifies View of changes
  4. View re-renders

Example:

mvc-form.js
// Model: holds application state
class UserModel {
constructor() {
this.name = '';
this.email = '';
this.listeners = [];
}

subscribe(listener) {
this.listeners.push(listener);
}

notify() {
this.listeners.forEach(listener => listener());
}

setName(name) {
this.name = name;
this.notify();
}

setEmail(email) {
this.email = email;
this.notify();
}
}

// View: renders UI
class UserView {
constructor(model) {
this.model = model;
this.nameInput = document.getElementById('name');
this.emailInput = document.getElementById('email');

this.model.subscribe(() => this.render());
}

render() {
this.nameInput.value = this.model.name;
this.emailInput.value = this.model.email;
}
}

// Controller: handles input
class UserController {
constructor(model, view) {
this.model = model;
this.view = view;

this.view.nameInput.addEventListener('change', (e) => {
this.model.setName(e.target.value);
});
}
}

// Wire it up
const model = new UserModel();
const view = new UserView(model);
const controller = new UserController(model, view);

Pros:

  • Clear separation of concerns
  • Easy to understand initially
  • Decouples UI from business logic

Cons:

  • Bidirectional communication (Model → View, View → Controller → Model) creates circular dependency
  • Hard to trace data flow (who changed what?)
  • Difficult to test (many dependencies)

Best for: Small applications, learning architectural concepts.

MVP (Model-View-Presenter)

Presenter intercepts all View-Model communication. View is completely passive.

Model ← Presenter → View (passive, dumb)

Flow:

  1. User interacts with View
  2. View forwards event to Presenter (no logic)
  3. Presenter updates Model
  4. Model notifies Presenter
  5. Presenter updates View

Example:

mvp-form.js
// Model: pure data
class UserModel {
constructor() {
this.name = '';
this.email = '';
}
}

// View: completely passive (dumb)
class UserView {
constructor() {
this.nameInput = document.getElementById('name');
this.emailInput = document.getElementById('email');
this.errorMsg = document.getElementById('error');
}

// View only exposes input; doesn't contain logic
getName() { return this.nameInput.value; }
getEmail() { return this.emailInput.value; }
setName(name) { this.nameInput.value = name; }
setEmail(email) { this.emailInput.value = email; }
showError(msg) { this.errorMsg.textContent = msg; }

onNameChange(callback) {
this.nameInput.addEventListener('change', callback);
}

onEmailChange(callback) {
this.emailInput.addEventListener('change', callback);
}
}

// Presenter: all logic here (fully testable)
class UserPresenter {
constructor(model, view) {
this.model = model;
this.view = view;

this.view.onNameChange(() => this.handleNameChange());
this.view.onEmailChange(() => this.handleEmailChange());
}

handleNameChange() {
const name = this.view.getName();
if (name.length < 2) {
this.view.showError('Name too short');
return;
}
this.model.name = name;
this.view.showError('');
}

handleEmailChange() {
const email = this.view.getEmail();
if (!email.includes('@')) {
this.view.showError('Invalid email');
return;
}
this.model.email = email;
this.view.showError('');
}
}

// Test Presenter (no View needed!)
test('validate name', () => {
const model = new UserModel();
const view = new MockView();
const presenter = new UserPresenter(model, view);

view.simulateNameChange('X');
expect(view.getError()).toBe('Name too short');
});

Pros:

  • Testable (Presenter doesn't depend on View)
  • View is truly dumb (no logic)
  • Clear one-way communication (View → Presenter → Model)

Cons:

  • Verbose (lots of getter/setter boilerplate)
  • More code than MVC
  • Synchronous only (harder for async operations)

Best for: Apps requiring extensive testing, Java Swing/Android developers.

MVVM (Model-View-ViewModel)

ViewModel exposes data to View via two-way data binding. View and ViewModel stay in sync automatically.

Model ← ViewModel ↔ View (bidirectional binding)

Flow:

  1. User types in View
  2. Binding automatically updates ViewModel
  3. ViewModel validates, computes
  4. Binding automatically updates View

Example:

mvvm-form.js
// ViewModel: exposes reactive data and methods
class UserViewModel {
constructor(model) {
this.model = model;
this.name = new Proxy({ value: '' }, {
set: (target, key, val) => {
target[key] = val;
this.validate();
return true;
}
});
this.email = new Proxy({ value: '' }, {
set: (target, key, val) => {
target[key] = val;
this.validate();
return true;
}
});
this.errors = {};
}

validate() {
if (this.name.value.length < 2) {
this.errors.name = 'Too short';
} else {
delete this.errors.name;
}

if (!this.email.value.includes('@')) {
this.errors.email = 'Invalid email';
} else {
delete this.errors.email;
}
}

submit() {
this.model.name = this.name.value;
this.model.email = this.email.value;
}
}

// View: binds to ViewModel
class UserView {
constructor(viewModel) {
this.vm = viewModel;
this.nameInput = document.getElementById('name');
this.emailInput = document.getElementById('email');
this.errorDiv = document.getElementById('errors');

// Two-way binding
this.nameInput.addEventListener('input', (e) => {
this.vm.name.value = e.target.value;
});

// Watch ViewModel for changes
setInterval(() => {
this.render();
}, 100);
}

render() {
this.nameInput.value = this.vm.name.value;
this.emailInput.value = this.vm.email.value;
this.errorDiv.textContent = Object.values(this.vm.errors).join(', ');
}
}

Pros:

  • Less boilerplate (two-way binding)
  • Automatic synchronization
  • Good for data-heavy UIs
  • Popular in Angular, Vue, WPF

Cons:

  • "Magic" binding hides complexity
  • Harder to debug (where did change originate?)
  • Performance issues if binding not optimized
  • Learning curve for reactive programming

Best for: Data-heavy apps, frameworks with built-in binding (Angular, Vue).

MVI (Model-View-Intent)

Unidirectional data flow. User intent triggers Model updates, which trigger View re-renders.

View (render Model)

│ (intent)

Model (compute from intent)

│ (update)

Intent (user action)

Flow:

  1. User action emits Intent
  2. Model processes Intent, returns new state
  3. View renders new state

Example:

mvi-form.js
// Model: pure function (reducer)
function userReducer(state, intent) {
switch (intent.type) {
case 'SET_NAME':
return { ...state, name: intent.payload };
case 'SET_EMAIL':
return { ...state, email: intent.payload };
case 'VALIDATE':
return {
...state,
errors: {
name: state.name.length < 2 ? 'Too short' : '',
email: state.email.includes('@') ? '' : 'Invalid',
}
};
default:
return state;
}
}

// View: pure function (given state, returns HTML)
function UserView(state) {
return `
<input id="name" value="${state.name}" />
<input id="email" value="${state.email}" />
<div id="errors">${Object.values(state.errors).join(', ')}</div>
`;
}

// Intent: user actions
function setupIntents(dispatch) {
document.getElementById('name').addEventListener('input', (e) => {
dispatch({ type: 'SET_NAME', payload: e.target.value });
dispatch({ type: 'VALIDATE' });
});

document.getElementById('email').addEventListener('input', (e) => {
dispatch({ type: 'SET_EMAIL', payload: e.target.value });
dispatch({ type: 'VALIDATE' });
});
}

// Wiring: Redux-style
const store = {
state: { name: '', email: '', errors: {} },

dispatch(intent) {
this.state = userReducer(this.state, intent);
this.render();
},

render() {
document.getElementById('app').innerHTML = UserView(this.state);
setupIntents((intent) => this.dispatch(intent));
},
};

store.render();

Pros:

  • Completely predictable (pure functions)
  • Easy to test (pure functions, no side effects)
  • Time-travel debugging (can replay intents)
  • Scales well to complex apps

Cons:

  • Most boilerplate
  • Steeper learning curve
  • Overkill for simple UIs

Best for: Large, complex applications, teams familiar with Elm/Redux.

Pattern Comparison

MVC:         Model ↔ Controller ↔ View  (bidirectional, circular)

MVP: View → Presenter → Model (unidirectional, but multiple layers)

MVVM: View ↔ ViewModel ↔ Model (bidirectional with binding)

MVI: Intent → Model → View (unidirectional, pure functions)
Data flow in each pattern

Patterns & Pitfalls

Pattern: Testability Pyramid

Easier to test:

  • Model (pure functions) ✓ Easiest
  • ViewModel/Presenter (logic isolated) ✓ Easy
  • View (DOM interactions) ✗ Hard to test
  • Integration (all layers) ✗ Hardest

Write more Model/ViewModel tests, fewer View tests.

Pitfall: God Object

Problem: ViewModel or Controller does everything (validation, API calls, caching).

Mitigation: Extract services:

// Bad: all logic in ViewModel
class CheckoutVM {
async submit() {
// Validate form
// Make API call
// Handle errors
// Update state
}
}

// Good: separate concerns
class CheckoutVM {
constructor(validationService, apiService) {
this.validator = validationService;
this.api = apiService;
}

async submit() {
const errors = await this.validator.validate(this.form);
if (errors) return;
const result = await this.api.submit(this.form);
this.state = result;
}
}

Pitfall: Binding Hell (MVVM)

Problem: Complex two-way bindings cause infinite loops, hard to debug.

Mitigation: Use one-way binding where possible, explicit methods for two-way.

Design Review Checklist

  • Is the chosen pattern appropriate for app complexity?
  • Are concerns separated (Model, View, Controller/ViewModel distinct)?
  • Is View truly presentation-only (no business logic)?
  • Is Model free of UI dependencies?
  • Are data flows unidirectional or well-documented if bidirectional?
  • Are logic layers testable (pure functions, mockable dependencies)?
  • Are async operations handled cleanly (not in reducers/pure functions)?
  • Can state changes be traced (what changed, when, why)?
  • Is boilerplate acceptable for the team's experience level?

When to Use / When Not to Use

Use MVC For:

  • Small apps (single page, few features)
  • Rapid prototyping
  • Teams learning architecture

Use MVP For:

  • Apps requiring extensive testing
  • Teams with strong testing culture
  • Java/Android developers

Use MVVM For:

  • Data-heavy UIs (dashboards, forms)
  • Frameworks with binding support (Angular, Vue)
  • Developers comfortable with reactive programming

Use MVI For:

  • Large, complex apps
  • Apps needing time-travel debugging
  • Teams familiar with functional programming

Showcase: Architecture Evolution

Self-Check

  1. Why is MVC harder to test than MVP? What changes in MVP?
  2. How does MVVM differ from MVC in handling user input?
  3. When would you choose unidirectional flow (MVP/MVI) over bidirectional (MVC/MVVM)?

Next Steps

One Takeaway

ℹ️

MVC/MVP/MVVM/MVI represent different trade-offs between simplicity and testability. Start with MVC for small apps. Graduate to MVVM for data-heavy UI or MVI for complex state. Choose based on team experience and app complexity, not dogma.

References

  1. MVC Pattern - Wikipedia
  2. MVVM Pattern - Wikipedia
  3. Cycle.js: Model-View-Intent
  4. GUI Architecture Patterns - Martin Fowler
  5. Android Architecture - Google