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:
// 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 dataBoth 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
| Aspect | MVC | MVP |
|---|---|---|
| View-Model link | Direct (View can query Model) | None (Presenter mediates) |
| Data flow | User → View → Controller → Model → View | User → View → Presenter → Model → Presenter → View |
| Testing | Harder (View needs real Model) | Easier (mock View interface) |
| Coupling | Medium (View knows Model structure) | Low (View is passive) |
| Boilerplate | Less | More (Presenter per View) |
| Frameworks | Ruby on Rails, ASP.NET MVC, Spring MVC | Android (pre-Compose), WinForms, GWT |
| When to use | Reactive web UIs, backend apps | Test-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
// 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.
// 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
// 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
// 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:
// 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:
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 readyA concise answer to help you respond confidently on this topic during an interview.