Skip to main content

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 досі зустрічаються на співбесідах

Швидкий приклад

Головна різниця в одному фрагменті коду:

javascript
// 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 тримає все в одному місці

Таблиця порівняння

АспектMVCMVP
Зв'язок 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 MVCAndroid (до 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

java
// Неправильно: View чіпає Model class LoginActivity implements LoginView { void onLogin() { if (model.authenticate(email)) showSuccess(); // залежність від Model } }

Це руйнує ізоляцію тестів. View стає залежним від Model, і замінити його у тестах вже не вийде. Виправлення: делегувати все до Presenter.

java
// Правильно: View тільки делегує class LoginActivity implements LoginView { void onLogin() { presenter.login(email, pass); } public void showSuccess() { /* перехід на dashboard */ } public void showError(String msg) { /* показати toast */ } }

Помилка: бізнес-логіка в MVC Controller

javascript
// Неправильно: валідація і запит до БД прямо в 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-ах

javascript
// Погано: один і той самий запит у двох місцях 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:

java
// 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 прямо в обробниках маршрутів:

javascript
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 раз - обидва маршрути отримують виправлення автоматично.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?