MVC (модель-подання-контролер) та MVP (модель-подання-презентер) патерни проектування
MVC (Model-View-Controller) та MVP (Model-View-Presenter) - це архітектурні патерни, які розбивають код застосунку на чіткі ролі: одна управляє даними, інша відповідає за відображення, третя стоїть між ними. Головна різниця полягає в тому, чи може View напряму звертатись до Model.
Теорія
TL;DR
- MVC: View може самостійно діставати дані з Model; Controller обробляє дії користувача
- MVP: View пасивний і нічого не знає про Model; Presenter керує всією логікою і сам передає дані до View
- Аналогія: MVC - ресторан, де відвідувачі (View) читають меню напряму; MVP ставить офіціанта (Presenter) єдиним посередником між відвідувачами і кухнею
- Правило вибору: обирай MVP коли потрібно тестувати Views без реального UI (Android, десктоп); MVC підходить для веб-фреймворків, де прив'язка даних обробляється автоматично
- Android перейшов від MVP до MVVM разом з Jetpack Compose, але питання про MVP досі зустрічаються на співбесідах
Швидкий приклад
Головна різниця в одному фрагменті коду:
// MVC: View напряму звертається до Model
class Model { getData() { return 'user data'; } }
class View {
constructor(model) { this.model = model; } // View тримає посилання на Model
render() { console.log(this.model.getData()); } // пряме звернення
}
const model = new Model();
new View(model).render(); // Output: user data
// MVP: View нічого не знає про Model, спілкується тільки з Presenter
class Presenter {
constructor(model, view) { this.model = model; this.view = view; }
updateView() { this.view.show(this.model.getData()); } // Presenter штовхає дані
}
class ViewMVP {
show(data) { console.log(data); } // немає посилання на model
}
new Presenter(model, new ViewMVP()).updateView(); // Output: user dataОбидва варіанти дають однаковий результат. Різниця - хто тримає зв'язок.
Головна різниця
У MVC View може спостерігати за Model або запитувати в неї дані. Це спрощує реактивний UI, але прив'язує View до структури Model - тести без живої Model стають складнішими. MVP розриває цей зв'язок повністю. View надає тільки методи на кшталт show(data) або showError(msg), і Presenter їх викликає. View ніколи не чіпає Model, а отже його легко замінити у тестах: передаєш підробний View у Presenter і перевіряєш, що він отримав.
Коли використовувати
- Веб-застосунки з data-binding фреймворками (React, Angular): MVC підходить, фреймворк синхронізує Controller і View автоматично
- Android-застосунки (до Compose) де потрібні unit-тести Views: MVP, мокуємо View-інтерфейс без залежності від Model
- TDD-проекти з жорсткою ізоляцією тестів: MVP, Presenter зосереджує всю логіку
- Прості прототипи: MVC, менше файлів, швидша ітерація
- Важка бізнес-логіка з частим рефакторингом: MVP, Presenter тримає все в одному місці
Таблиця порівняння
| Аспект | MVC | MVP |
|---|---|---|
| Зв'язок View-Model | Прямий (View може запитувати Model) | Відсутній (Presenter посередник) |
| Потік даних | Користувач → View → Controller → Model → View | Користувач → View → Presenter → Model → Presenter → View |
| Тестування | Складніше (View потребує живої Model) | Простіше (мокуємо View-інтерфейс) |
| Зв'язаність | Середня (View знає структуру Model) | Низька (View пасивний) |
| Boilerplate | Менше | Більше (Presenter для кожного View) |
| Фреймворки | Ruby on Rails, ASP.NET MVC, Spring MVC | Android (до Compose), WinForms, GWT |
| Коли застосовувати | Реактивний веб-UI, бекенд | Застосунки з великою кількістю тестів, Android |
Як патерни (design patterns) працюють у фреймворках
У Ruby on Rails роутер надсилає HTTP-запити до Controllers, ті запитують дані через ActiveRecord і рендерять Views (ERB-шаблони). View має доступ до даних, переданих Controller-ом, і може викликати методи Model напряму.
В Android MVP Activity реалізує View-інтерфейс. Presenter тримає посилання на цей інтерфейс, а не на Activity безпосередньо. Dependency injection (Dagger, Hilt) пов'язує їх під час виконання. Саме це дає змогу підміняти реальний Activity на мок у тестах без жодних проблем.
З власного досвіду: найгірші баги в MVC-проектах виникають не через архітектуру, а через Controller-и на 200 рядків, де зосередились валідація, запити до БД і відправка email одночасно. Дисципліна MVP з Presenter-ом допомагає цього уникнути.
Типові помилки
Помилка: виклик Model напряму з MVP View
// Неправильно: View чіпає Model
class LoginActivity implements LoginView {
void onLogin() {
if (model.authenticate(email)) showSuccess(); // залежність від Model
}
}Це руйнує ізоляцію тестів. View стає залежним від Model, і замінити його у тестах вже не вийде. Виправлення: делегувати все до Presenter.
// Правильно: View тільки делегує
class LoginActivity implements LoginView {
void onLogin() { presenter.login(email, pass); }
public void showSuccess() { /* перехід на dashboard */ }
public void showError(String msg) { /* показати toast */ }
}Помилка: бізнес-логіка в MVC Controller
// Неправильно: валідація і запит до БД прямо в 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 в Controller
res.json(result);
});Переноси логіку в Model. Controller повинен тільки маршрутизувати запити і повертати відповіді.
Помилка: ігнорування подій lifecycle у MVP
Якщо користувач натискає "назад" або повертає телефон, Presenter має про це знати. Забутий виклик presenter.onCancel() або presenter.onDestroy() призводить до розсинхронізації стану і витоків пам'яті, бо Presenter тримає посилання на View, якого вже немає.
Помилка: дублювання запитів у різних MVC Controller-ах
// Погано: один і той самий запит у двох місцях
app.get('/users', (req, res) => {
const users = db.query('SELECT * FROM users WHERE active=1'); // вбудований запит
res.json(users);
});
app.get('/user/:id', (req, res) => {
const user = db.query('SELECT * FROM users WHERE id=? AND active=1', [req.params.id]); // дублювання
res.json(user);
});
// Виправлення: виносимо в 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]); }
}Дубльовані запити - одне з найпоширеніших джерел N+1-проблем в Express MVC-проектах.
Де зустрічається у продакшені
- Ruby on Rails: повноцінний MVC, Models через ActiveRecord, Views на ERB-шаблонах
- ASP.NET MVC: Controllers обробляють HTTP, Models через Entity Framework Core, Views на Razor
- Spring MVC: Controllers з анотацією
@Controller, Views через Thymeleaf або JSP - Android (до Compose): MVP де Activity реалізує View-інтерфейс, Presenter переживає зміни конфігурації
- GWT (Google Web Toolkit): MVP для компіляції Java в JS, Presenter управляє DOM-подіями
- WinForms: варіант MVP для десктопних застосунків з тестованим UI
Питання на співбесіді
Q: Намалюй потік даних MVC та MVP на дошці.
A: MVC: користувач → View → Controller → Model → View (або View сам опитує Model). MVP: користувач → View викликає метод Presenter → Presenter оновлює Model → Presenter викликає метод View з новими даними.
Q: Як unit-тестувати MVP Presenter?
A: Мокуєш View-інтерфейс через Mockito: LoginView mockView = mock(LoginView.class). Передаєш у Presenter, викликаєш presenter.login("bad@email.com", "wrong"), перевіряєш verify(mockView).showError("Invalid credentials"). Без Activity, без UI, без емулятора.
Q: Який реальний мінус додаткового шару в MVP?
A: Boilerplate. Кожному View потрібен Presenter і зазвичай інтерфейс. У великому застосунку це сотні додаткових класів. Для невеликих фіч виглядає як оверінжиніринг.
Q: Чому Android відмовився від MVP?
A: Jetpack Compose змінив підхід до UI - декларативний рендеринг на основі стану усуває потребу в пасивному View. MVVM з LiveData або StateFlow підходить краще: ViewModel нативно переживає зміни конфігурації, а управління lifecycle Presenter-а завжди було ручним і схильним до помилок.
Q: (Senior) Як масштабувати MVP, коли кілька Views ділять одну Model?
A: Окремий Presenter для кожного View, кожен з власним View-інтерфейсом. Model ділиться через dependency injection (один екземпляр у DI-графі) або через pub-sub канал на кшталт RxJava subjects. Спільний змінний стан між Presenter-ами повертає той самий coupling, від якого ти тікав.
Приклади
Android: форма логіну (MVP)
Реальний екран авторизації за MVP-підходом, що використовувався в Android до Compose:
// Model: чиста бізнес-логіка, без Android-імпортів
class UserModel {
boolean authenticate(String email, String pass) {
return email.equals("test@test.com") && pass.equals("pass");
}
}
// View-інтерфейс: що Presenter може викликати в UI
interface LoginView {
void showSuccess();
void showError(String msg);
}
// Presenter: керує логікою, спілкується з Model і 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 реалізує View-інтерфейс, делегує всі дії 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 нічого не знає про Model
}
public void showSuccess() { /* перехід на dashboard */ }
public void showError(String msg) { /* показати toast з msg */ }
}Тестування тривіальне: мокуємо LoginView, передаємо в LoginPresenter, викликаємо presenter.login(...), перевіряємо який метод View було викликано. Без емулятора, без Activity.
Express.js MVC - тонкі Controller-и
Виправлена версія типової помилки - логіка Model прямо в обробниках маршрутів:
const express = require('express');
const app = express();
// Шар Model: визначення запитів тут, не в Controller-ах
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]);
}
}
// Controller-и залишаються тонкими: тільки маршрутизація і відповідь
app.get('/users', async (req, res) => {
const model = new UserModel();
const users = await model.getActiveUsers(); // логіка в Model, не тут
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);
});Визначення запиту живе в одному місці. Змінюєш умову WHERE active=1 раз - обидва маршрути отримують виправлення автоматично.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.