Коли ваш застосунок був маленьким — одна сторінка, десяток рядків коду — все здавалося простим. Але той момент, коли логіка відображення, бізнес-правила та управління даними починають переплітатися в одному файлі, стає початком архітектурного кошмару. Саме цю проблему вирішив патерн, якому вже понад 40 років: Model-View-Controller (MVC).
У цій статті ми не пишемо жодного рядка ASP.NET коду — ми вивчаємо ідею. Бо розробник, який розуміє чому патерн існує, пише кращий код, ніж той, хто просто знає як його застосувати.
Уявіть типовий PHP-файл з 2005 року — або будь-який код без архітектури:
<?php
// Отримання даних з бази
$conn = mysqli_connect("localhost", "root", "", "shop");
$result = mysqli_query($conn, "SELECT * FROM products WHERE category = " . $_GET['cat']);
// Бізнес-логіка прямо тут
$products = [];
while ($row = mysqli_fetch_assoc($result)) {
if ($row['stock'] > 0 && $row['price'] > 0) {
$row['discount'] = $row['price'] * 0.1; // 10% знижка
$products[] = $row;
}
}
// HTML розмітка тут же
echo "<html><body>";
echo "<h1>Категорія: " . $_GET['cat'] . "</h1>";
foreach ($products as $p) {
echo "<div>" . $p['name'] . " — " . $p['price'] . " грн</div>";
}
echo "</body></html>";
?>
Цей код робить три принципово різні речі в одному місці:
Наслідки:
$_GET['cat'] — привіт, хакериСаме тут на сцену виходить Separation of Concerns (розділення відповідальностей) — один із фундаментальних принципів програмування.
Принцип Separation of Concerns: кожна частина програми повинна відповідати лише за одну чітко визначену функцію. Зміна одного аспекту не повинна вимагати змін в іншому.
1979 рік. Норвезький вчений Trygve Mikkjel Heyerdahl Reenskaug працює в Xerox PARC над мовою SmallTalk. Він вирішує проблему, яка мучила розробників GUI-застосунків (графічний інтерфейс): як відокремити дані від їх відображення так, щоб одні й ті самі дані можна було показувати по-різному?
Його рішення — три пов'язані компоненти:
У 1980 році це з'явилося в SmallTalk-80 як офіційний патерн. Трюгве Реєнскауг описав його так:
«MVC was conceived as a general solution to the problem of users controlling a large and complex data set.»
Але тоді йшлося про desktop-застосунки з графічним інтерфейсом, де View реагував на зміни Model у реальному часі через Observer pattern. Для веб це виявилося трохи інакше.

1996 рік. Java-фреймворк Struts адаптує MVC для веб. 2004 рік. Ruby on Rails робить MVC мейнстримом. 2008 рік. Microsoft випускає ASP.NET MVC. З того часу фреймворки Django (Python), Laravel (PHP), Spring MVC (Java), Angular (TypeScript) — всі так чи інакше реалізують цей 40-річний патерн.
Розберемо кожен компонент на прикладі інтернет-магазину.
Model — це не просто клас з полями. Це все, що пов'язане з даними та бізнес-логікою:
Domain Model
Product, Order, Customer. Вони відображають реальний світ і містять бізнес-правила: «товар не може мати від'ємну ціну», «замовлення без позицій не може бути підтверджено».Repository / Data Access
Business Logic / Services
Ключове правило: Model нічого не знає про View і Controller. Він незалежний. Саме тому ті ж дані продукту можна відобразити і як HTML-сторінку, і як JSON для API, і як PDF-звіт.
View — це шаблон, який перетворює дані на те, що бачить користувач. У веб-контексті це HTML+CSS, але може бути і JSON, XML, PDF.
Ключове правило: View не містить бізнес-логіки. Він не обчислює знижки — він лише відображає те, що отримав. Якщо у View є if (price > 1000) { showFreeShipping(); } — це вже бізнес-правило, якому не місце у View.
View може містити логіку відображення (iterating over a list, conditional CSS classes) — але не бізнес-логіку.
Controller — це посередник. Він отримує запит від користувача, координує взаємодію між Model і View, і повертає результат.
Аналогія з ресторану:
Ключове правило: Controller тонкий (thin controller). Він не містить бізнес-логіки — він лише делегує. «Fat Controller» (контролер з бізнес-логікою) — це анти-патерн.
❌ Fat Controller (анти-патерн):
Controller отримує запит → обчислює знижки → перевіряє склад →
формує email → зберігає замовлення → повертає View
✅ Thin Controller (правильно):
Controller отримує запит → викликає OrderService.CreateOrder() →
повертає View з результатом
У веб-застосунку запит проходить чіткий шлях:
Браузер надсилає GET /products/42. Маршрутизатор (Router) аналізує URL і визначає: цей запит має обробити ProductController, метод Details, з параметром id = 42.
ProductController.Details(42) викликається. Controller — це просто C#-клас з методами (Actions). Action-метод визначає, що потрібно зробити.
Controller каже: «Дай мені продукт з id = 42». ProductRepository.GetById(42) повертає об'єкт Product. Жодного HTML тут немає.
Controller вибирає, який View показати, і передає туди отриманий Product. «Ось дані — відобрази їх».
Шаблон .cshtml отримує Product і генерує HTML: назва, ціна, фото, кнопка «Купити». Жодної логіки тут немає — тільки шаблон.
Згенерований HTML повертається браузеру. Запит завершено.
@startuml
skinparam backgroundColor #FAFAFA
skinparam sequenceArrowThickness 2
actor Browser
participant Router
participant Controller
participant Model
participant View
Browser -> Router : GET /products/42
Router -> Controller : ProductController.Details(42)
Controller -> Model : ProductRepository.GetById(42)
Model --> Controller : Product { Id=42, Name="Laptop", Price=45000 }
Controller -> View : Details.cshtml + Product
View --> Controller : "<html>...</html>"
Controller --> Browser : HTTP 200 + HTML
@enduml
Важливо розуміти: веб-MVC відрізняється від оригінального MVC Реєнскауга.
| Аспект | Класичний MVC (desktop) | Веб-MVC |
|---|---|---|
| Взаємодія | View і Model напряму пов'язані через Observer | View і Model не взаємодіють напряму |
| Оновлення | View автоматично оновлюється при зміні Model | Кожен запит — новий цикл Request→Response |
| Стан | View зберігає стан (open windows) | HTTP stateless — стан не зберігається між запитами |
| Controller | Обробляє input (клавіатура, миша) | Обробляє HTTP-запити |
У веб-MVC (включаючи ASP.NET Core MVC) View та Model не взаємодіють напряму — Controller завжди посередник. Це спрощення оригінальної ідеї, але воно ідеально підходить для stateless HTTP-протоколу.
MVC породив сімейство схожих патернів:
Де використовується: Android (традиційний), WinForms, класичні мобільні застосунки.
Відмінність від MVC: Presenter (аналог Controller) — це посередник між Model та View, але View пасивний: він не знає про Model взагалі. Presenter отримує дані і сам оновлює View через інтерфейс.
View → Presenter → Model
↓
View (через інтерфейс IView)
Коли краще: коли View потрібно ізолювати для юніт-тестів (View — це інтерфейс, легко підмінити mock).
Де використовується: WPF, Blazor, Angular, Vue.js, SwiftUI.
Відмінність: ViewModel — це не Controller і не Presenter. Це «адаптований Model для View». View прив'язується до ViewModel через Data Binding і автоматично оновлюється при зміні ViewModel (через INotifyPropertyChanged або Observable).
View ←[Data Binding]→ ViewModel → Model
Коли краще: реактивні UI з двостороннім зв'язуванням даних.
Де використовується: Django (Python).
Відмінність: Template (аналог View) — статичний шаблон. View (аналог Controller) — функція або клас, що обробляє запит і повертає Response. Назви компонентів відмінні, але ідея та сама.
# Django "View" — насправді Controller у термінах MVC
def product_detail(request, pk):
product = Product.objects.get(pk=pk) # Model
return render(request, 'product.html', {'product': product}) # Template = View
| MVC | MVP | MVVM | MVT | |
|---|---|---|---|---|
| Посередник | Controller | Presenter | ViewModel | View (Django) |
| View знає про Model? | Ні | Ні | Через binding | Ні |
| Тестованість View | Складна | Легка | Легка | Середня |
| Data Binding | Ні | Ні | Так | Ні |
| Де зустрічається | ASP.NET, Rails, Laravel | Android legacy, WinForms | Blazor, Vue, WPF | Django |
Розглянемо, як різні сценарії розподіляються між M, V і C без жодного коду — лише архітектурне мислення.
Сценарій: користувач купує товар
Хто це обробляє?
[Форма "Купити"] → натискання кнопки
↓
C: OrderController.Create(productId, quantity)
— перевіряє, що формат запиту коректний
— передає дані у OrderService
M: OrderService.PlaceOrder(userId, productId, quantity)
— перевіряє наявність товару на складі
— розраховує вартість з урахуванням знижок
— резервує товар
— зберігає замовлення у БД
— ставить завдання на відправку email (фоновий сервіс)
C: отримує результат від M
— якщо успіх → вибирає View "OrderConfirmation"
— якщо помилка (нема на складі) → вибирає View "OutOfStock"
V: OrderConfirmation.cshtml
— відображає номер замовлення, деталі, суму
— кнопка "Перейти до замовлень"
Що відбувається при зміні вимог:
| Зміна | Що змінюється | Що НЕ змінюється |
|---|---|---|
| Новий дизайн сторінки підтвердження | View | Model, Controller |
| Нове правило знижок (5% → 10%) | Model (OrderService) | View, Controller |
Нова URL-структура /checkout/confirm | Controller (routing) | Model, View |
| Додати мобільний API ендпоінт | Новий Controller (ApiController) + новий View (JSON) | Model (той самий OrderService) |
Ось у чому сила MVC: зміни ізольовані. Дизайнер, бізнес-аналітик і backend-розробник можуть працювати паралельно над різними компонентами.
ASP.NET Core MVC реалізує веб-адаптацію патерну з кількома суттєвими відмінностями від оригіналу:
ViewModel — об'єкт, спеціально створений для конкретного View з потрібними полями.Оскільки ви вже знайомі з ASP.NET Core Minimal API, у вас може виникнути логічне запитання: навіщо нам MVC, якщо Minimal API такі швидкі і лаконічні?
Minimal API чудово підходять для створення мікросервісів, легких REST-інтерфейсів та застосунків з невеликою кількістю ендпоінтів. Проте, коли застосунок росте:
Controllers/, Views/, Models/), що полегшує навігацію у командній розробці.Коротке правило: API для інших програм → Minimal API. Надійний моноліт з HTML-інтерфейсом (або великим REST API з жорсткою структурою) → MVC.
Fat Controller — найпоширеніша помилка початківців у MVC:
❌ Fat Controller (все в Controller):
OrderController.Create() {
// Перевірка наявності на складі
var stock = db.Products.Find(id).Stock;
if (stock == 0) return View("Error");
// Розрахунок знижки
var discount = user.IsVip ? 0.15 : 0.05;
var finalPrice = price * (1 - discount);
// Збереження
db.Orders.Add(new Order { ... });
db.SaveChanges();
// Відправка email
var smtp = new SmtpClient("mail.example.com");
smtp.Send(new MailMessage { ... });
return View("Success");
}
Усе, що тут є, крім вибору View — не місце у Controller.
✅ Thin Controller (правильно):
OrderController.Create(CreateOrderDto dto) {
var result = await _orderService.PlaceOrderAsync(dto);
return result.IsSuccess
? View("Success", result.Order)
: View("Error", result.Error);
}
Controller має дві відповідальності:
Все інше — в Model (Services).
Завдання 1.1. Для кожного фрагменту коду визначте, до якого компоненту MVC він належить (M, V або C), і обґрунтуйте:
var discount = order.TotalPrice > 1000 ? 0.1m : 0m;<h1>@Model.ProductName</h1>return RedirectToAction("Index");var products = await _repository.GetAllAsync();if (!ModelState.IsValid) return View(model);foreach (var item in Model.CartItems) { ... } (у .cshtml)Завдання 1.2. Наведіть 3 приклади логіки відображення (яка допустима у View) та 3 приклади бізнес-логіки (яка у View заборонена). Поясніть різницю.
Завдання 2.1. Вам потрібно реалізувати функцію «Забули пароль?». Распишіть, що робить кожен компонент (M, V, C) для кожного кроку:
Завдання 2.2. Ваш колега каже: «У мене View сам звертається до бази даних — це ж зручніше, бо не треба передавати дані через Controller!» Напишіть аргументований технічний відгук: чому це погана ідея і які конкретні проблеми це спричинить.
Завдання 3.1. Спроєктуйте MVC-архітектуру для системи бронювання готелів. Визначте:
Оформіть у вигляді схеми або таблиці.
Separation of Concerns
Model: незалежний
View: пасивний
Controller: тонкий
У наступній статті ми перейдемо від теорії до практики: побачимо, як ці принципи реалізовані в ASP.NET Core MVC, і зробимо перший крок — перетворимо знайому Razor Page на Controller + Action.
Практичний проєкт: TaskManager на Razor Pages
Повний практичний CRUD-проєкт TaskManager на Razor Pages: список задач з пошуком і пагінацією, форми створення та редагування з валідацією, підтвердження видалення, категорії, пріоритети, статуси, спільний Layout з навігацією, Partial Views, IMemoryCache, EF Core з PostgreSQL.
Від Razor Pages до MVC: концептуальний перехід
Плавний перехід від Razor Pages до ASP.NET Core MVC: порівняльна таблиця PageModel vs Controller+Action, мінімальні зміни в Program.cs, коли обирати MVC, і покроковий демо-проєкт — перетворення Razor Page на Controller.