ASP.NET Core MVC

Від Razor Pages до MVC: концептуальний перехід

Плавний перехід від Razor Pages до ASP.NET Core MVC: порівняльна таблиця PageModel vs Controller+Action, мінімальні зміни в Program.cs, коли обирати MVC, і покроковий демо-проєкт — перетворення Razor Page на Controller.

Від Razor Pages до MVC: концептуальний перехід

Ви вже знаєте 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}.

Аналогія:

  • Razor Pages: кожен касир у магазині відповідає за свою касу повністю (знає і обробляти оплату, і консультувати, і видавати товар). Одна Касира Сторінка = одна відповідальність-сторінка.
  • MVC: є менеджер відділу (Controller) який делегує конкретні задачі різним консультантам (Actions). 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
Pages/Products/Index.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();
    }
}
Pages/Products/Create.cshtml.cs
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).

Ключові відмінності у таблиці

КонцепціяRazor PagesMVC
Одиниця організації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)
Передача данихВластивості PageModelViewData, ViewBag, або typed model
Редиректreturn RedirectToPage("./Index")return RedirectToAction(nameof(Index))
404return NotFound()return NotFound() ← однаково!
МаршрутФайлова структура Pages/Convention: {controller}/{action}
Маршрут (явний)@page "{id:int}"[HttpGet("{id}")] або [Route("...")]
DI у хендлеріКонструктор PageModelКонструктор Controller ← однаково!
Middlewareapp.Use(...)app.Use(...) ← однаково!
DI реєстраціяbuilder.Services.Add...()builder.Services.Add...() ← однаково!

Зверніть увагу: багато речей — абсолютно однакові. DI, middleware, конфігурація, логування, сесії — все те саме, що ви вже знаєте.


Коли обирати MVC над Razor Pages?

MVC — правильний вибір

  • Один контролер, багато Actions: наприклад, ProductController з Index, Details, Create, Edit, Delete, Search, Export — логічно групувати
  • Areas: великий проєкт з розділами Admin/Shop/Blog
  • API + SSR в одному проєкті: ProductApiController : ControllerBase (JSON) + ProductController : Controller (HTML)
  • Команда звикла до MVC: більшість enterprise-проєктів на .NET використовують MVC
  • Legacy: міграція існуючого ASP.NET MVC 4/5 проєкту

Razor Pages — правильний вибір

  • Сторінко-центричний застосунок: кожна сторінка відносно незалежна
  • Простіший код: менше концепцій, легше для початківців
  • Адмін-панелі і внутрішні інструменти: де архітектура важливіша за швидкість
  • Мала команда: Razor Pages менш церемоніальний
Обидва підходи можна поєднувати в одному проєкті: Razor Pages для адмін-панелі, MVC Controller для публічної частини, API Controller для мобільних клієнтів. ASP.NET Core дозволяє це без проблем.

Що змінюється в Program.cs

Це найменша різниця з усіх. Ви вже знаєте паттерн 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();

Лише два рядки відрізняються:

  • AddRazorPages()AddControllersWithViews()
  • MapRazorPages()MapDefaultControllerRoute()

Все інше — DI, middleware, конфігурація, логування — ідентичне.


Структура проєкту MVC

Порівняйте зі знайомою структурою 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

Ключова конвенція 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 на Controller

Зробімо це покроково. Беремо просту Razor Page, перетворюємо на MVC Controller. Щоб приклад був повністю самодостатнім — починаємо з нуля: модель, сервіс, реєстрація DI.

Крок 0: Модель і сервіс (спільні для обох підходів)

Ці файли однакові і для Razor Pages, і для MVC — ось ключова ідея: бізнес-логіка не прив'язана до підходу.

Models/Book.cs
namespace BookApp.Models;

public record Book(int Id, string Title, string Author, int Year);
Services/IBookService.cs
namespace BookApp.Services;

public interface IBookService
{
    Task<List<Book>> GetAllAsync();
    Task<List<Book>> SearchAsync(string? term);
    Task<Book?> GetByIdAsync(int id);
}
Services/InMemoryBookService.cs
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 (те, що ми перетворюємо):

Pages/Books/Index.cshtml.cs
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);
    }
}
Pages/Books/Index.cshtml
@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>

Крок 1: Створюємо Controller

Controllers/BookController.cs
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
    }
}

Крок 2: Переносимо View (мінімальні зміни)

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>
}

Крок 3: Реєструємо у Program.cs

Program.cs
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();

Крок 4: Перевіряємо маршрут

Razor PagesMVC
GET /booksPages/Books/Index.cshtml)GET /bookBookController.Index())
За замовчуванням MVC-маршрут: {controller=Home}/{action=Index}/{id?}. BookController/book. Якщо потрібно /books — використайте [Route("books")] на Controller. У наступній статті розберемо це детально.

Що залишилось незмінним

Перелічимо все, що ви вже знаєте і воно повністю працює в MVC:

  • DI: builder.Services.AddScoped<T>() — ідентично
  • Конфігурація: IConfiguration, Options pattern — ідентично
  • Логування: ILogger<T> — ідентично
  • Middleware: app.Use...() — ідентично
  • Razor синтаксис: @foreach, @if, @Model, @ViewBag — ідентично
  • Tag Helpers: asp-for, asp-controller, asp-action, asp-validation-for — ті самі + нові
  • _Layout.cshtml: ідентично
  • _ViewImports.cshtml: ідентично (тільки шлях інший)
  • Аутентифікація: [Authorize] — ідентично
  • ModelState: ModelState.IsValid — ідентично
  • IFormFile: завантаження файлів — ідентично
  • Сесії, кешування, SignalR — ідентично

Практичні завдання

Рівень 1 — Базовий

Завдання 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 — Логіка

Завдання 2.1. Перетворіть наступну Razor Page на MVC Controller. Збережіть всю функціональність:

Pages/Notes/Create.cshtml.cs
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 — Архітектура

Завдання 3.1. Проєкт Task Manager з Razor Pages (що ви вже будували) потрібно частково перенести на MVC. Спроєктуйте:

  • Які PageModel-и стають одним TaskController (групуються)?
  • Які залишаються окремими (і чому не варто їх об'єднувати)?
  • Намалюйте структуру Controllers/ і Views/ для нової версії.

Резюме

Перехід від Razor Pages до MVC — це зміна організації коду, а не технології. Змінюються:

  • PageModelController з Action-методами
  • [BindProperty] → параметри методу
  • return Page()return View(model)
  • AddRazorPages()AddControllersWithViews()
  • Файлова структура: Pages/Controllers/ + Views/{Controller}/

Залишається незмінним: абсолютно все інше — DI, middleware, Razor синтаксис, Tag Helpers, конфігурація, логування.

У наступній статті — детальний розгляд Controller і Action: всі типи результатів, атрибути HTTP-методів, та побудова повноцінного LibraryController.