Ви вже знаєте Razor Pages: PageModel, OnGet, OnPost, [BindProperty], файлова структура Pages/. Ви будували сторінки, форми, Task Manager. Тепер — крок убік до ASP.NET Core MVC: той самий фреймворк, інший підхід до організації коду.
Не кращий, не гірший — інший. І цей матеріал покаже вам точно, що змінюється, а що залишається незмінним.
Razor Pages — page-centric (сторінко-центричний) підхід: кожна сторінка — окрема одиниця з власним PageModel. Маршрут визначається файловою структурою.
MVC — action-centric підхід: Controller — це клас з кількома Action-методами. Маршрут визначається явно або через конвенцію {controller}/{action}/{id}.
Аналогія:
ProductController знає і про список товарів, і про деталі, і про форму додавання.Найкращий спосіб зрозуміти різницю — побачити одну й ту саму задачу в обох підходах.
Задача: CRUD для товарів — список, деталі, створення, редагування.
6 файлів для CRUD:
Pages/Products/
├── Index.cshtml → GET /products
├── Index.cshtml.cs
├── Details.cshtml → GET /products/details/{id}
├── Details.cshtml.cs
├── Create.cshtml → GET/POST /products/create
├── Create.cshtml.cs
├── Edit.cshtml → GET/POST /products/edit/{id}
└── Edit.cshtml.cs
public class IndexModel : PageModel
{
private readonly IProductService _svc;
public IndexModel(IProductService svc) => _svc = svc;
public List<Product> Products { get; set; } = [];
public async Task OnGetAsync()
{
Products = await _svc.GetAllAsync();
}
}
public class CreateModel : PageModel
{
private readonly IProductService _svc;
public CreateModel(IProductService svc) => _svc = svc;
[BindProperty]
public CreateProductInput Input { get; set; } = new();
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page();
await _svc.CreateAsync(Input);
return RedirectToPage("./Index");
}
}
Маршрути виводяться з файлової структури. Немає явного оголошення маршрутів (крім @page у .cshtml).
1 Controller + 4 Views для того самого CRUD:
Controllers/
└── ProductController.cs ← один файл, всі дії
Views/Product/
├── Index.cshtml → GET /product
├── Details.cshtml → GET /product/details/{id}
├── Create.cshtml → GET /product/create
└── Edit.cshtml → GET /product/edit/{id}
public class ProductController : Controller
{
private readonly IProductService _svc;
public ProductController(IProductService svc) => _svc = svc;
// GET /product
public async Task<IActionResult> Index()
{
var products = await _svc.GetAllAsync();
return View(products); // → Views/Product/Index.cshtml
}
// GET /product/details/42
public async Task<IActionResult> Details(int id)
{
var product = await _svc.GetByIdAsync(id);
if (product is null) return NotFound();
return View(product); // → Views/Product/Details.cshtml
}
// GET /product/create
public IActionResult Create() => View();
// POST /product/create
[HttpPost]
public async Task<IActionResult> Create(CreateProductInput input)
{
if (!ModelState.IsValid) return View(input);
await _svc.CreateAsync(input);
return RedirectToAction(nameof(Index));
}
}
Маршрути — явні або конвенційні. Controller + Action = маршрут.
| Концепція | Razor Pages | MVC |
|---|---|---|
| Одиниця організації | PageModel (one per page) | Controller з кількома Actions |
| GET-хендлер | OnGet() / OnGetAsync() | public IActionResult ActionName() |
| POST-хендлер | OnPost() / OnPostAsync() | [HttpPost] public IActionResult ActionName(model) |
| Прив'язка форми | [BindProperty] на властивості | Параметри методу (автоматично) |
| Успішна відповідь | return Page() | return View() або return View(model) |
| Передача даних | Властивості PageModel | ViewData, ViewBag, або typed model |
| Редирект | return RedirectToPage("./Index") | return RedirectToAction(nameof(Index)) |
| 404 | return NotFound() | return NotFound() ← однаково! |
| Маршрут | Файлова структура Pages/ | Convention: {controller}/{action} |
| Маршрут (явний) | @page "{id:int}" | [HttpGet("{id}")] або [Route("...")] |
| DI у хендлері | Конструктор PageModel | Конструктор Controller ← однаково! |
| Middleware | app.Use(...) | app.Use(...) ← однаково! |
| DI реєстрація | builder.Services.Add...() | builder.Services.Add...() ← однаково! |
Зверніть увагу: багато речей — абсолютно однакові. DI, middleware, конфігурація, логування, сесії — все те саме, що ви вже знаєте.
MVC — правильний вибір
ProductController з Index, Details, Create, Edit, Delete, Search, Export — логічно групуватиProductApiController : ControllerBase (JSON) + ProductController : Controller (HTML)Razor Pages — правильний вибір
Program.csЦе найменша різниця з усіх. Ви вже знаєте паттерн Program.cs — підсвітимо лише зміни:
var builder = WebApplication.CreateBuilder(args);
// Ваші сервіси — без змін
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddMemoryCache();
// ↓ Razor Pages специфіка
builder.Services.AddRazorPages();
var app = builder.Build();
// Middleware — без змін
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
// ↓ Razor Pages специфіка
app.MapRazorPages();
app.Run();
var builder = WebApplication.CreateBuilder(args);
// Ваші сервіси — точно ті самі, без жодних змін
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddMemoryCache();
// ↓ MVC специфіка (замість AddRazorPages)
builder.Services.AddControllersWithViews();
var app = builder.Build();
// Middleware — точно той самий, без жодних змін
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
// ↓ MVC специфіка (замість MapRazorPages)
app.MapDefaultControllerRoute();
// Або явно: app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();
Лише два рядки відрізняються:
AddRazorPages() → AddControllersWithViews()MapRazorPages() → MapDefaultControllerRoute()Все інше — DI, middleware, конфігурація, логування — ідентичне.
Порівняйте зі знайомою структурою Razor Pages:
MyApp/
├── Pages/
│ ├── _Layout.cshtml
│ ├── _ViewStart.cshtml
│ ├── _ViewImports.cshtml
│ ├── Index.cshtml
│ ├── Index.cshtml.cs
│ └── Products/
│ ├── Index.cshtml
│ ├── Index.cshtml.cs
│ ├── Create.cshtml
│ └── Create.cshtml.cs
├── wwwroot/
├── Program.cs
└── appsettings.json
MyApp/
├── Controllers/ ← нова папка
│ ├── HomeController.cs
│ └── ProductController.cs
├── Views/ ← окрема папка (не Pages/)
│ ├── Shared/
│ │ ├── _Layout.cshtml ← те саме
│ │ └── _ValidationScriptsPartial.cshtml
│ ├── _ViewStart.cshtml ← те саме
│ ├── _ViewImports.cshtml ← те саме
│ ├── Home/
│ │ └── Index.cshtml
│ └── Product/ ← папка = Controller name
│ ├── Index.cshtml ← Action name
│ ├── Details.cshtml
│ ├── Create.cshtml
│ └── Edit.cshtml
├── Models/ ← нова папка для ViewModels
│ └── ProductViewModel.cs
├── wwwroot/ ← те саме
├── Program.cs ← мінімальні зміни
└── appsettings.json ← те саме
Ключова конвенція MVC: Views організовані за Controllers/ → папка з ім'ям Controller → файли з іменами Actions.
ProductController.Index() → шукає Views/Product/Index.cshtmlProductController.Details() → шукає Views/Product/Details.cshtml
_Layout.cshtml, _ViewStart.cshtml, _ViewImports.cshtml — ті самі концепції що і в Razor Pages, тільки тепер в Views/Shared/ замість Pages/Shared/. Синтаксис і поведінка — ідентичні.Зробімо це покроково. Беремо просту Razor Page, перетворюємо на MVC Controller. Щоб приклад був повністю самодостатнім — починаємо з нуля: модель, сервіс, реєстрація DI.
Ці файли однакові і для Razor Pages, і для MVC — ось ключова ідея: бізнес-логіка не прив'язана до підходу.
namespace BookApp.Models;
public record Book(int Id, string Title, string Author, int Year);
namespace BookApp.Services;
public interface IBookService
{
Task<List<Book>> GetAllAsync();
Task<List<Book>> SearchAsync(string? term);
Task<Book?> GetByIdAsync(int id);
}
using BookApp.Models;
namespace BookApp.Services;
// Проста реалізація в пам'яті — для демонстрації без БД
public class InMemoryBookService : IBookService
{
private readonly List<Book> _books =
[
new(1, "Кобзар", "Тарас Шевченко", 1840),
new(2, "Тіні забутих предків", "Михайло Коцюбинський", 1911),
new(3, "Місто", "Валер'ян Підмогильний", 1928),
new(4, "Майстер корабля", "Юрій Яновський", 1928),
new(5, "Лісова пісня", "Леся Українка", 1911),
];
public Task<List<Book>> GetAllAsync() =>
Task.FromResult(_books.ToList());
public Task<List<Book>> SearchAsync(string? term)
{
if (string.IsNullOrWhiteSpace(term))
return GetAllAsync();
var result = _books
.Where(b => b.Title.Contains(term, StringComparison.OrdinalIgnoreCase)
|| b.Author.Contains(term, StringComparison.OrdinalIgnoreCase))
.ToList();
return Task.FromResult(result);
}
public Task<Book?> GetByIdAsync(int id) =>
Task.FromResult(_books.FirstOrDefault(b => b.Id == id));
}
Вихідна Razor Page (те, що ми перетворюємо):
using BookApp.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace BookApp.Pages.Books;
public class IndexModel : PageModel
{
private readonly IBookService _svc;
public IndexModel(IBookService svc) => _svc = svc;
public List<Book> Books { get; set; } = [];
public string? SearchTerm { get; set; }
public async Task OnGetAsync([FromQuery] string? search)
{
SearchTerm = search;
Books = await _svc.SearchAsync(search);
}
}
@page
@model BookApp.Pages.Books.IndexModel
<h1>Книги</h1>
<form method="get">
<input type="text" name="search" value="@Model.SearchTerm"
class="form-control d-inline w-auto">
<button type="submit" class="btn btn-primary">Шукати</button>
</form>
<ul class="mt-3">
@foreach (var book in Model.Books)
{
<li>@book.Title — @book.Author (@book.Year)</li>
}
</ul>
using BookApp.Models;
using BookApp.Services;
using Microsoft.AspNetCore.Mvc;
namespace BookApp.Controllers;
// 1. Наслідуємо від Controller (не PageModel)
public class BookController : Controller
{
private readonly IBookService _svc;
// 2. DI — той самий конструктор
public BookController(IBookService svc) => _svc = svc;
// 3. OnGetAsync → public async Task<IActionResult> Index
// У MVC немає [BindProperty]. Усі вхідні дані (з URL, форми, query)
// передаються виключно як параметри методу (Model Binding).
public async Task<IActionResult> Index(string? search)
{
var books = await _svc.SearchAsync(search);
// 4. Передаємо дані у View
// Замість властивостей PageModel → typed model або ViewBag.
// (ViewBag — це динамічний словник для дрібних даних,
// який ми детально розберемо у статті 06)
ViewBag.SearchTerm = search;
return View(books); // → Views/Book/Index.cshtml
}
}
@* 1. Прибираємо @page — це не Razor Page *@
@* 2. @model тепер List<Book>, а не IndexModel *@
@model List<Book>
<h1>Книги</h1>
<form method="get">
@* 3. SearchTerm тепер з ViewBag замість Model.SearchTerm *@
<input type="text" name="search" value="@ViewBag.SearchTerm">
<button type="submit">Шукати</button>
</form>
@* 4. Foreach по Model напряму (не Model.Books) *@
@foreach (var book in Model)
{
<div>@book.Title — @book.Author</div>
}
using BookApp.Services;
var builder = WebApplication.CreateBuilder(args);
// ↓ Реєструємо наш сервіс — без жодних змін порівняно з Razor Pages
builder.Services.AddSingleton<IBookService, InMemoryBookService>();
// Замінюємо один рядок:
// builder.Services.AddRazorPages(); ← видаляємо
builder.Services.AddControllersWithViews(); // ← додаємо
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
// app.MapRazorPages(); ← видаляємо
app.MapDefaultControllerRoute(); // ← додаємо
app.Run();
| Razor Pages | MVC |
|---|---|
GET /books (з Pages/Books/Index.cshtml) | GET /book (з BookController.Index()) |
{controller=Home}/{action=Index}/{id?}. BookController → /book. Якщо потрібно /books — використайте [Route("books")] на Controller. У наступній статті розберемо це детально.Перелічимо все, що ви вже знаєте і воно повністю працює в MVC:
builder.Services.AddScoped<T>() — ідентичноIConfiguration, Options pattern — ідентичноILogger<T> — ідентичноapp.Use...() — ідентично@foreach, @if, @Model, @ViewBag — ідентичноasp-for, asp-controller, asp-action, asp-validation-for — ті самі + нові_Layout.cshtml: ідентично_ViewImports.cshtml: ідентично (тільки шлях інший)[Authorize] — ідентичноModelState.IsValid — ідентичноIFormFile: завантаження файлів — ідентичноЗавдання 1.1. Створіть новий ASP.NET Core MVC проєкт командою dotnet new mvc -n BookStore. Порівняйте його структуру зі структурою dotnet new webapp. Знайдіть всі однакові файли і всі відмінності. Запишіть у таблицю.
Завдання 1.2. Відкрийте Controllers/HomeController.cs у новому проєкті. Знайдіть аналоги:
OnGet() / OnGetAsync() — що це тепер?return Page() — що це тепер?[BindProperty] — де тепер живуть вхідні дані?Завдання 2.1. Перетворіть наступну Razor Page на MVC Controller. Збережіть всю функціональність:
public class CreateModel : PageModel
{
private readonly INoteService _svc;
public CreateModel(INoteService svc) => _svc = svc;
[BindProperty]
public string Title { get; set; } = "";
[BindProperty]
public string Content { get; set; } = "";
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page();
await _svc.CreateAsync(Title, Content);
TempData["Success"] = "Нотатку створено!";
return RedirectToPage("./Index");
}
}
Завдання 2.2. В MVC-проєкті створіть ContactController з двома Actions: Index (форма зворотного зв'язку, GET) і Submit (обробка форми, POST). При успішній відправці — TempData з повідомленням і redirect на Index.
Завдання 3.1. Проєкт Task Manager з Razor Pages (що ви вже будували) потрібно частково перенести на MVC. Спроєктуйте:
PageModel-и стають одним TaskController (групуються)?Controllers/ і Views/ для нової версії.Перехід від Razor Pages до MVC — це зміна організації коду, а не технології. Змінюються:
PageModel → Controller з Action-методами[BindProperty] → параметри методуreturn Page() → return View(model)AddRazorPages() → AddControllersWithViews()Pages/ → Controllers/ + Views/{Controller}/Залишається незмінним: абсолютно все інше — DI, middleware, Razor синтаксис, Tag Helpers, конфігурація, логування.
У наступній статті — детальний розгляд Controller і Action: всі типи результатів, атрибути HTTP-методів, та побудова повноцінного LibraryController.
Патерн MVC: архітектура, що змінила веб
Глибоке занурення у патерн Model-View-Controller: його історія від SmallTalk-80, ролі компонентів, lifecycle HTTP-запиту, варіації MVP/MVVM/MVT, та чому ASP.NET MVC — не зовсім «класичний» MVC. Без коду — тільки концепції та діаграми.
Controllers та Actions: серце MVC
Глибокий розгляд Controller як класу в ASP.NET Core MVC: Controller vs ControllerBase,ієрархія IActionResult, всі типи результатів, HTTP-атрибути, контекст запиту. Демо-проєкт: LibraryController з повним CRUD покроково.