Skip to main content

MVC (model-view-controller) and MVP (model-view-presenter) design patterns

MVC (Model-View-Controller) and MVP (Model-View-Presenter) are architectural patterns that split application code into distinct roles: one manages data, one handles display, and one sits between them. The real difference is whether the View can talk to the Model directly.

Theory

TL;DR

  • MVC: the View can pull data from the Model itself; Controller handles user input
  • MVP: the View is passive and knows nothing about the Model; Presenter owns all logic and pushes data to View
  • Analogy: MVC is a restaurant where customers (View) can read the menu directly; MVP puts the waiter (Presenter) as the only link between customers and the kitchen
  • Decision rule: pick MVP when you need to unit test Views without a real UI (Android, desktop); MVC works for web frameworks that handle binding automatically
  • Android moved away from MVP toward MVVM with Jetpack Compose, but MVP questions still come up in interviews regularly

Quick Example

The core difference in one runnable snippet:

javascript
// MVC: View directly accesses Model class Model { getData() { return 'user data'; } } class View { constructor(model) { this.model = model; } // View holds Model reference render() { console.log(this.model.getData()); } // direct pull } const model = new Model(); new View(model).render(); // Output: user data // MVP: View knows nothing about Model, only talks to Presenter class Presenter { constructor(model, view) { this.model = model; this.view = view; } updateView() { this.view.show(this.model.getData()); } // Presenter pushes } class ViewMVP { show(data) { console.log(data); } // no model reference here } new Presenter(model, new ViewMVP()).updateView(); // Output: user data

Both produce the same output. The difference is who holds the connection.

Key Difference

In MVC, the View can observe or query the Model for real-time updates. That simplifies reactive UIs but ties the View to the Model's structure, which makes testing harder without a live Model. MVP breaks this link completely. The View exposes only methods like show(data) or showError(msg), and the Presenter calls them. The View never touches the Model, which makes it mockable in tests: pass a fake View to the Presenter and verify what it receives.

When to Use

  • Web apps with data-binding frameworks (React, Angular): MVC works well, the framework handles Controller-View sync automatically
  • Android apps (pre-Compose) that need View unit tests: MVP, mock the View interface without touching the Model
  • TDD projects with strict test isolation: MVP, Presenter centralizes all logic
  • Simple prototypes: MVC, fewer files, faster iteration
  • Heavy business logic that gets refactored often: MVP, Presenter owns everything in one place

Comparison Table

AspectMVCMVP
View-Model linkDirect (View can query Model)None (Presenter mediates)
Data flowUser → View → Controller → Model → ViewUser → View → Presenter → Model → Presenter → View
TestingHarder (View needs real Model)Easier (mock View interface)
CouplingMedium (View knows Model structure)Low (View is passive)
BoilerplateLessMore (Presenter per View)
FrameworksRuby on Rails, ASP.NET MVC, Spring MVCAndroid (pre-Compose), WinForms, GWT
When to useReactive web UIs, backend appsTest-heavy apps, Android login flows

How the Patterns Work in Frameworks

In Ruby on Rails, the router dispatches HTTP requests to Controllers, which query Models via ActiveRecord and render Views (ERB templates). The View has direct access to data passed by the Controller, and in some cases can call Model methods directly.

In Android MVP, the Activity implements a View interface. The Presenter holds a reference to that interface, not to the Activity itself. Dependency injection (Dagger, Hilt) wires them at runtime. This is what makes swapping a real Activity for a mock View in tests trivial.

One thing I noticed in production codebases: the worst bugs in MVC projects are not really about architecture. They come from Controllers ballooning to 200-line god objects because developers put validation, DB queries, and email sending all in one route handler. MVP's Presenter discipline forces that separation.

Common Mistakes

Mistake: calling Model directly from an MVP View

java
// Wrong: View touching Model directly class LoginActivity implements LoginView { void onLogin() { if (model.authenticate(email)) showSuccess(); // coupled to Model } }

This defeats test isolation - the View can no longer be mocked cleanly. Fix: delegate everything to the Presenter.

java
// Correct: View only delegates class LoginActivity implements LoginView { void onLogin() { presenter.login(email, pass); } public void showSuccess() { /* navigate */ } public void showError(String msg) { /* show toast */ } }

Mistake: business logic in MVC Controllers

javascript
// Wrong: validation and DB query inside the Controller app.post('/order', (req, res) => { if (!req.body.item) return res.status(400).json({ error: 'missing item' }); const result = db.query('INSERT INTO orders ...'); // Model logic here res.json(result); });

Push logic to the Model. Controllers should only route requests and return responses.

Mistake: ignoring View lifecycle events in MVP

If the user presses back or rotates the screen, the Presenter needs to know. Forgetting presenter.onCancel() or presenter.onDestroy() causes state drift and memory leaks, because the Presenter holds a reference to a View that no longer exists.

Mistake: duplicating Model queries across MVC Controllers

javascript
// BAD: same query written twice app.get('/users', (req, res) => { const users = db.query('SELECT * FROM users WHERE active=1'); // inline res.json(users); }); app.get('/user/:id', (req, res) => { const user = db.query('SELECT * FROM users WHERE id=? AND active=1', [req.params.id]); // duplicate res.json(user); }); // FIXED: extract to UserModel class UserModel { async getActive() { return db.query('SELECT * FROM users WHERE active=1'); } async getById(id) { return db.query('SELECT * FROM users WHERE id=? AND active=1', [id]); } }

Duplicate inline queries are a common source of N+1 bugs in Express codebases.

Real-world Usage

  • Ruby on Rails: full MVC, Models via ActiveRecord, Views via ERB templates
  • ASP.NET MVC: Controllers handle HTTP, Models via Entity Framework Core, Views via Razor
  • Spring MVC: Controllers annotated with @Controller, Views via Thymeleaf or JSP
  • Android (pre-Compose): MVP with Activity as View, Presenter survives config changes via retained fragments
  • GWT (Google Web Toolkit): MVP for Java-to-JS compilation, Presenter manages DOM events
  • WinForms: MVP variant for testable desktop UI

Follow-up Questions

Q: Draw the data flow for MVC vs MVP on a whiteboard.
A: MVC: user triggers View → View notifies Controller → Controller updates Model → Model notifies View (or View polls). MVP: user triggers View → View calls Presenter method → Presenter updates Model → Presenter calls View method with new data.

Q: How would you unit test an MVP Presenter?
A: Mock the View interface with Mockito in Java: LoginView mockView = mock(LoginView.class). Inject it into the Presenter, call presenter.login("bad@email.com", "wrong"), then verify verify(mockView).showError("Invalid credentials"). No Activity, no UI, no emulator needed.

Q: What is the real downside of MVP's extra layer?
A: Boilerplate. Every View needs a matching Presenter and usually an interface. In a large app that means hundreds of extra classes. For small features it feels like over-engineering.

Q: Why did Android move away from MVP?
A: Jetpack Compose changed how UI works - declarative state-driven rendering removes the need for a passive View. MVVM with LiveData or StateFlow fits better because ViewModel survives configuration changes natively, while Presenter lifecycle management was always manual and error-prone.

Q: (Senior) How do you handle multiple Views sharing one Model in MVP?
A: Create a separate Presenter per View, each owning its own View interface. Share the Model via dependency injection (single instance in the DI graph) or a pub-sub channel like RxJava subjects. Never share mutable state between Presenters directly - that reintroduces the coupling you were trying to avoid.

Examples

Android Login Flow (MVP)

A real-world login screen following the MVP pattern used in Android before Compose:

java
// Model: pure business logic, no Android imports class UserModel { boolean authenticate(String email, String pass) { return email.equals("test@test.com") && pass.equals("pass"); } } // View interface: what the Presenter can call on the UI interface LoginView { void showSuccess(); void showError(String msg); } // Presenter: owns all logic, mediates between Model and View class LoginPresenter { private UserModel model; private LoginView view; LoginPresenter(UserModel m, LoginView v) { model = m; view = v; } void login(String email, String pass) { if (model.authenticate(email, pass)) view.showSuccess(); else view.showError("Invalid credentials"); } } // Activity implements View interface, delegates everything to Presenter public class LoginActivity implements LoginView { LoginPresenter presenter = new LoginPresenter(new UserModel(), this); public void onLoginClick(String email, String pass) { presenter.login(email, pass); // Activity knows nothing about Model } public void showSuccess() { /* navigate to dashboard */ } public void showError(String msg) { /* show toast with msg */ } }

Testing this is straightforward: mock LoginView, inject into LoginPresenter, call presenter.login(...), verify which View method was called. No emulator, no Activity, fast test.

Express.js MVC - Keeping Controllers Thin

A common pattern in Express apps is writing Model logic directly inside route handlers. Here is the fixed version:

javascript
const express = require('express'); const app = express(); // Model layer: query definitions live here, not in controllers class UserModel { async getActiveUsers() { return db.query('SELECT * FROM users WHERE active=1'); } async getActiveUser(id) { return db.query('SELECT * FROM users WHERE id=? AND active=1', [id]); } } // Controllers stay thin: routing and response only app.get('/users', async (req, res) => { const model = new UserModel(); const users = await model.getActiveUsers(); res.json(users); }); app.get('/user/:id', async (req, res) => { const model = new UserModel(); const user = await model.getActiveUser(req.params.id); res.json(user); });

The query definition lives in one place. Change the WHERE active=1 condition once, and both endpoints get the fix automatically.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?