Уявіть: ваш застосунок виріс. У ньому є публічна частина для читачів, адміністративна панель для редакторів і API для мобільного застосунку. Усі три частини мають власні Controllers, Views і моделі — і їх вже не один десяток. Папка Controllers/ перетворилася на хаос: HomeController, ArticleController, AdminDashboardController, AdminArticleController, AdminUserController...
Саме для цього сценарію в ASP.NET Core MVC існують Areas (Області). Area — це спосіб розбити великий застосунок на логічно ізольовані модулі, кожен зі своїми Controllers, Views та моделями, але в межах одного застосунку. На відміну від мікросервісів, Area — це не окремий процес і не окрема збірка: все залишається в одному проєкті, але організовано набагато чистіше.
Pages/, але без явного поняття «area». Minimal API Areas не має взагалі.Area — це іменована підструктура всередині MVC-застосунку. Технічно кожна Area:
Areas/{AreaName}/Controllers/ та Areas/{AreaName}/Views/MapAreaControllerRoute[Area("AreaName")]Ключова перевага: ізоляція імен. Ви можете мати AdminArea/Controllers/ArticleController та PublicArea/Controllers/ArticleController — два класи з однаковою назвою, але в різних областях. Маршрутизатор правильно розрізняє їх за префіксом URL (/admin/article vs /article).
Конвенційна структура для Areas виглядає так:
YourProject/
├── Areas/
│ ├── Admin/
│ │ ├── Controllers/
│ │ │ ├── DashboardController.cs ← [Area("Admin")]
│ │ │ ├── ArticleController.cs ← [Area("Admin")]
│ │ │ └── UserController.cs ← [Area("Admin")]
│ │ └── Views/
│ │ ├── Dashboard/
│ │ │ └── Index.cshtml
│ │ ├── Article/
│ │ │ ├── Index.cshtml
│ │ │ └── Edit.cshtml
│ │ └── Shared/
│ │ └── _AdminLayout.cshtml ← окремий Layout для Admin
│ └── Public/
│ ├── Controllers/
│ │ ├── ArticleController.cs ← [Area("Public")]
│ │ └── HomeController.cs ← [Area("Public")]
│ └── Views/
│ ├── Article/
│ │ ├── Index.cshtml
│ │ └── Details.cshtml
│ └── Shared/
│ └── _PublicLayout.cshtml
├── Controllers/ ← Controllers БЕЗ Area (за потреби)
│ └── ErrorController.cs
└── Views/
└── Shared/
└── _Layout.cshtml ← спільний Layout (якщо є)
Area — це не просто папка. Структура Areas/{AreaName}/Views/ повністю дзеркалить стандартну структуру Views/: є Shared/ для спільних View та _ViewStart.cshtml/_ViewImports.cshtml для кожної Area окремо.
[Area]Кожен Controller, що належить до Area, обов'язково позначається атрибутом [Area("AreaName")]. Без цього атрибуту Controller вважається частиною «кореневого» застосунку, навіть якщо фізично знаходиться у папці Areas/.
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Areas.Admin.Controllers;
[Area("Admin")] // ← обов'язково: приналежність до Area
public class DashboardController : Controller
{
public IActionResult Index()
{
return View();
}
}
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Areas.Public.Controllers;
[Area("Public")] // ← інша Area, інший namespace
public class ArticleController : Controller
{
public IActionResult Index()
{
return View();
}
public IActionResult Details(int id)
{
return View();
}
}
Зверніть увагу на namespace: рекомендована конвенція — {ProjectName}.Areas.{AreaName}.Controllers. Це не обов'язково, але робить код зрозумілим.
Стандартний MapDefaultControllerRoute не знає про Areas. Для кожної Area потрібно явно зареєструвати маршрут через MapAreaControllerRoute:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
// ── Маршрути Areas ──────────────────────────────────────────────
// Admin Area: /admin/{controller}/{action}/{id?}
app.MapAreaControllerRoute(
name: "admin", // ← ім'я маршруту (унікальне)
areaName: "Admin", // ← має збігатися з [Area("Admin")]
pattern: "admin/{controller=Dashboard}/{action=Index}/{id?}"
);
// Public Area: /public/{controller}/{action}/{id?}
// Або можна зробити Public "кореневою" — тоді без префіксу
app.MapAreaControllerRoute(
name: "public",
areaName: "Public",
pattern: "{controller=Article}/{action=Index}/{id?}"
);
// Стандартний маршрут для Controllers без Area (за потреби)
app.MapDefaultControllerRoute();
app.Run();
MapDefaultControllerRoute.Альтернатива для досвідчених — один шаблон маршруту з токеном {area}:
// Єдиний маршрут що охоплює всі Areas
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"
);
app.MapDefaultControllerRoute(); // для Controllers без Area
Токен {area:exists} — це route constraint: маршрут спрацює лише якщо існує Area з такою назвою. Без цього обмеження {area} захоплював би будь-який сегмент URL.
_ViewImports.cshtml та _ViewStart.cshtml для кожної AreaКожна Area може мати власні _ViewImports.cshtml та _ViewStart.cshtml:
@using BlogApp.Areas.Admin.Controllers
@using BlogApp.Areas.Admin.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
// Admin Area використовує власний Layout
Layout = "_AdminLayout";
}
@{
// Public Area використовує стандартний Layout
Layout = "~/Views/Shared/_Layout.cshtml";
}
Це дозволяє Admin Area мати зовсім інший дизайн (темна адмін-панель) ніж Public Area (публічний сайт), без жодного if у _Layout.
asp-areaУ Razor Views стандартні Tag Helpers для посилань набули нового атрибуту — asp-area:
@* Посилання В МЕЖАХ поточної Area — asp-area не потрібен *@
<a asp-controller="Article" asp-action="Index">Всі статті</a>
@* Посилання НА ІНШУ Area *@
<a asp-area="Admin" asp-controller="Dashboard" asp-action="Index">
Адмін-панель
</a>
@* Посилання НА Area з параметром маршруту *@
<a asp-area="Public" asp-controller="Article" asp-action="Details"
asp-route-id="@article.Id">
Читати статтю
</a>
@* Посилання З Area у «корінь» (без Area) *@
<a asp-area="" asp-controller="Error" asp-action="NotFound">
Сторінка 404
</a>
asp-area можна не вказувати, він успадковується з поточного маршруту. Якщо ж хочете вийти за межі Area — обов'язково вкажіть asp-area="" (порожній рядок для кореневих Controllers) або ім'я іншої Area.Коли Action повертає View() без аргументів у Area-Controller, ASP.NET Core шукає View у такому порядку:
1. Areas/{AreaName}/Views/{ControllerName}/{ActionName}.cshtml
2. Areas/{AreaName}/Views/Shared/{ActionName}.cshtml
3. Views/Shared/{ActionName}.cshtml
Тобто спочатку — специфічна View Area, потім — Shared усередині Area, потім — глобальний Shared. Це означає: Area може перевизначити будь-який Shared-шаблон локально, не чіпаючи глобальних Views.
[Area("Admin")]
public class ArticleController : Controller
{
public IActionResult Index()
{
// Шукатиме: Areas/Admin/Views/Article/Index.cshtml
return View();
}
public IActionResult SharedView()
{
// Явно вказуємо шлях до View в іншій Area або корені:
return View("~/Views/Shared/AccessDenied.cshtml");
}
}
Побудуємо мінімальний, але повністю функціональний застосунок із двома Area: Admin (управління статтями) та Public (читання статей).
namespace BlogApp.Models;
public record Article(
int Id,
string Title,
string Content,
string Author,
DateTime PublishedAt,
bool IsPublished
);
using BlogApp.Models;
namespace BlogApp.Services;
public interface IArticleService
{
Task<IReadOnlyList<Article>> GetAllAsync(bool onlyPublished = false);
Task<Article?> GetByIdAsync(int id);
Task<Article> CreateAsync(string title, string content, string author);
Task<Article> TogglePublishAsync(int id);
Task DeleteAsync(int id);
}
using BlogApp.Models;
namespace BlogApp.Services;
// Простий in-memory сервіс для демонстрації
public class InMemoryArticleService : IArticleService
{
private readonly List<Article> _articles =
[
new(1, "Вступ до ASP.NET Core MVC", "MVC — це архітектурний патерн...",
"Іван Коваль", DateTime.Now.AddDays(-10), IsPublished: true),
new(2, "Маршрутизація в MVC", "Convention та Attribute Routing...",
"Марія Ткач", DateTime.Now.AddDays(-5), IsPublished: true),
new(3, "Filters: AOP в MVC", "Розглянемо filter pipeline...",
"Олег Степан", DateTime.Now.AddDays(-1), IsPublished: false),
];
private int _nextId = 4;
public Task<IReadOnlyList<Article>> GetAllAsync(bool onlyPublished = false)
{
var result = onlyPublished
? _articles.Where(a => a.IsPublished).ToList()
: _articles.ToList();
return Task.FromResult<IReadOnlyList<Article>>(result);
}
public Task<Article?> GetByIdAsync(int id)
=> Task.FromResult(_articles.FirstOrDefault(a => a.Id == id));
public Task<Article> CreateAsync(string title, string content, string author)
{
var article = new Article(_nextId++, title, content, author,
DateTime.Now, IsPublished: false);
_articles.Add(article);
return Task.FromResult(article);
}
public Task<Article> TogglePublishAsync(int id)
{
var index = _articles.FindIndex(a => a.Id == id);
if (index < 0) throw new InvalidOperationException($"Article {id} not found");
var old = _articles[index];
var updated = old with { IsPublished = !old.IsPublished };
_articles[index] = updated;
return Task.FromResult(updated);
}
public Task DeleteAsync(int id)
{
_articles.RemoveAll(a => a.Id == id);
return Task.CompletedTask;
}
}
using BlogApp.Services;
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Areas.Admin.Controllers;
[Area("Admin")]
public class DashboardController : Controller
{
private readonly IArticleService _articles;
public DashboardController(IArticleService articles)
{
_articles = articles;
}
public async Task<IActionResult> Index()
{
var all = await _articles.GetAllAsync();
ViewBag.TotalArticles = all.Count;
ViewBag.PublishedArticles = all.Count(a => a.IsPublished);
ViewBag.DraftArticles = all.Count(a => !a.IsPublished);
return View();
}
}
using BlogApp.Services;
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Areas.Admin.Controllers;
[Area("Admin")]
public class ArticleController : Controller
{
private readonly IArticleService _articles;
public ArticleController(IArticleService articles)
{
_articles = articles;
}
// GET /admin/article
public async Task<IActionResult> Index()
{
var articles = await _articles.GetAllAsync(); // всі, включно з чернетками
return View(articles);
}
// GET /admin/article/create
[HttpGet]
public IActionResult Create() => View();
// POST /admin/article/create
[HttpPost]
public async Task<IActionResult> Create(string title, string content, string author)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content))
{
ModelState.AddModelError("", "Заголовок та зміст обов'язкові");
return View();
}
await _articles.CreateAsync(title, content, author);
TempData["Success"] = $"Статтю «{title}» створено як чернетку.";
return RedirectToAction(nameof(Index));
}
// POST /admin/article/togglepublish/5
[HttpPost]
public async Task<IActionResult> TogglePublish(int id)
{
var article = await _articles.TogglePublishAsync(id);
TempData["Success"] = article.IsPublished
? $"«{article.Title}» опубліковано."
: $"«{article.Title}» повернуто у чернетки.";
return RedirectToAction(nameof(Index));
}
// POST /admin/article/delete/5
[HttpPost]
public async Task<IActionResult> Delete(int id)
{
var article = await _articles.GetByIdAsync(id);
await _articles.DeleteAsync(id);
TempData["Success"] = $"Статтю «{article?.Title}» видалено.";
return RedirectToAction(nameof(Index));
}
}
using BlogApp.Services;
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Areas.Public.Controllers;
[Area("Public")]
public class ArticleController : Controller
{
private readonly IArticleService _articles;
public ArticleController(IArticleService articles)
{
_articles = articles;
}
// GET / або /public/article
public async Task<IActionResult> Index()
{
// Публічна сторінка — лише опубліковані статті
var articles = await _articles.GetAllAsync(onlyPublished: true);
return View(articles);
}
// GET /public/article/details/5
public async Task<IActionResult> Details(int id)
{
var article = await _articles.GetByIdAsync(id);
if (article is null || !article.IsPublished) return NotFound();
return View(article);
}
}
@{ ViewData["Title"] = "Дашборд"; }
<h1>Адмін-панель</h1>
<div class="row">
<div class="col-md-4">
<div class="card text-white bg-primary mb-3">
<div class="card-body">
<h5 class="card-title">Всього статей</h5>
<p class="card-text display-4">@ViewBag.TotalArticles</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-success mb-3">
<div class="card-body">
<h5 class="card-title">Опублікованих</h5>
<p class="card-text display-4">@ViewBag.PublishedArticles</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-warning mb-3">
<div class="card-body">
<h5 class="card-title">Чернеток</h5>
<p class="card-text display-4">@ViewBag.DraftArticles</p>
</div>
</div>
</div>
</div>
<a asp-area="Admin" asp-controller="Article" asp-action="Index"
class="btn btn-outline-primary">Управління статтями</a>
<a asp-area="Public" asp-controller="Article" asp-action="Index"
class="btn btn-outline-secondary">Переглянути сайт</a>
@using BlogApp.Models
@model IReadOnlyList<Article>
@{ ViewData["Title"] = "Управління статтями"; }
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Статті <span class="badge bg-secondary">@Model.Count</span></h1>
<a asp-area="Admin" asp-controller="Article" asp-action="Create"
class="btn btn-primary">+ Нова стаття</a>
</div>
@if (TempData["Success"] is string msg)
{
<div class="alert alert-success alert-dismissible fade show">
@msg <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<table class="table table-striped">
<thead>
<tr>
<th>Заголовок</th>
<th>Автор</th>
<th>Статус</th>
<th>Дата</th>
<th>Дії</th>
</tr>
</thead>
<tbody>
@foreach (var article in Model)
{
<tr>
<td>@article.Title</td>
<td>@article.Author</td>
<td>
@if (article.IsPublished)
{
<span class="badge bg-success">Опубліковано</span>
}
else
{
<span class="badge bg-warning text-dark">Чернетка</span>
}
</td>
<td>@article.PublishedAt.ToString("dd.MM.yyyy")</td>
<td class="d-flex gap-2">
@* Cross-area: перейти до публічного перегляду *@
@if (article.IsPublished)
{
<a asp-area="Public" asp-controller="Article"
asp-action="Details" asp-route-id="@article.Id"
class="btn btn-sm btn-outline-secondary" target="_blank">
Переглянути
</a>
}
<form asp-area="Admin" asp-controller="Article"
asp-action="TogglePublish" asp-route-id="@article.Id"
method="post" class="d-inline">
<button class="btn btn-sm @(article.IsPublished ? "btn-warning" : "btn-success")">
@(article.IsPublished ? "В чернетки" : "Опублікувати")
</button>
</form>
<form asp-area="Admin" asp-controller="Article"
asp-action="Delete" asp-route-id="@article.Id"
method="post" class="d-inline"
onsubmit="return confirm('Видалити «@article.Title»?')">
<button class="btn btn-sm btn-danger">Видалити</button>
</form>
</td>
</tr>
}
</tbody>
</table>
@using BlogApp.Models
@model IReadOnlyList<Article>
@{ ViewData["Title"] = "Блог"; }
<h1>Останні статті</h1>
<p class="text-muted">@Model.Count опубліковани(х) матеріалів</p>
@if (!Model.Any())
{
<p class="text-muted">Статей поки немає. Заходьте пізніше.</p>
}
<div class="row row-cols-1 row-cols-md-2 g-4">
@foreach (var article in Model)
{
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">@article.Title</h5>
<p class="card-text text-muted">
@article.Author · @article.PublishedAt.ToString("dd MMMM yyyy")
</p>
<p class="card-text">
@(article.Content.Length > 100
? article.Content[..100] + "..."
: article.Content)
</p>
</div>
<div class="card-footer bg-transparent">
<a asp-area="Public" asp-controller="Article"
asp-action="Details" asp-route-id="@article.Id"
class="btn btn-outline-primary btn-sm">Читати →</a>
</div>
</div>
</div>
}
</div>
using BlogApp.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton<IArticleService, InMemoryArticleService>();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
// Admin Area: /admin/{controller=Dashboard}/{action=Index}/{id?}
app.MapAreaControllerRoute(
name: "admin",
areaName: "Admin",
pattern: "admin/{controller=Dashboard}/{action=Index}/{id?}"
);
// Public Area — «корінь» застосунку
app.MapAreaControllerRoute(
name: "public",
areaName: "Public",
pattern: "{controller=Article}/{action=Index}/{id?}"
);
// Fallback для Controllers без Area
app.MapDefaultControllerRoute();
app.Run();
Після запуску:
GET / → Areas/Public/Controllers/ArticleController.Index — список опублікованих статейGET /admin → Areas/Admin/Controllers/DashboardController.Index — адмін-дашбордGET /admin/article → Areas/Admin/Controllers/ArticleController.Index — управління статтямиPOST /admin/article/create → створення нової статтіGET /article/details/1 → Areas/Public/Controllers/ArticleController.Details(1) — статтяAmbiguousMatchException» — конфлікт маршрутівСимптом: AmbiguousMatchException: The request matched multiple endpoints.
Причина: У застосунку є два Controllers з однаковою назвою у різних Areas, але маршрут не дозволяє ASP.NET Core розрізнити їх.
Рішення: Переконайтеся що:
[Area("...")]MapAreaControllerRouteMapAreaControllerRoute до MapDefaultControllerRoute)Симптом: InvalidOperationException: The view 'Index' was not found.
Причина: View розміщена не там, де її шукає MVC.
Рішення: Перевірте структуру папок:
Areas/{AreaName}/Views/{ControllerName}/{ActionName}.cshtml
Назва папки {ControllerName} — це ім'я класу без суфікса Controller. Якщо клас ArticleController — папка має називатися Article.
asp-area не враховується у формахСимптом: Форма з asp-area="Admin" генерує URL без /admin префікса.
Причина: View знаходиться поза Area, і поточний контекст маршруту не містить значення area.
Рішення: Завжди явно вказуйте asp-area у формах і посиланнях, коли переходите між Areas. Ніколи не покладайтеся на неявне успадкування area зі стрічки маршруту у cross-area контексті.
Завдання 1.1. У демо-проєкті додайте до Admin Area новий UserController з Action Index, що повертає View зі списком трьох захардкоджених імен користувачів. Додайте посилання на UserController.Index з DashboardController.Index. Переконайтеся що URL /admin/user працює коректно.
Завдання 1.2. Поясніть що відбудеться, якщо у Areas/Admin/Controllers/ArticleController.cs прибрати атрибут [Area("Admin")]. Як зміниться поведінка маршрутизатора? Перевірте свою відповідь практично.
Завдання 2.1. Реалізуйте окремий Layout для Admin Area: Areas/Admin/Views/Shared/_AdminLayout.cshtml. Layout має містити: темний навбар з посиланнями «Дашборд», «Статті», «Користувачі» та посиланням «Перейти на сайт» що веде на Public/Article/Index. Додайте _ViewStart.cshtml у Areas/Admin/Views/ що вказує на _AdminLayout.
Завдання 2.2. Реалізуйте пошук у Public Area: Areas/Public/Controllers/ArticleController.Search(string query). Action повертає список опублікованих статей, в заголовку або змісті яких зустрічається query (case-insensitive). Додайте форму пошуку у Areas/Public/Views/Article/Index.cshtml що GET-методом надсилає запит на Search.
Завдання 3.1. Додайте до застосунку третю Area — API — для JSON-відповідей. Areas/Api/Controllers/ArticleController.cs успадковує від ControllerBase (не Controller), повертає ActionResult<T> з JSON. Маршрут: /api/articles (GET всі опубліковані), /api/articles/{id} (GET деталі). Зареєструйте маршрут Area. Перевірте через браузер або Postman що /api/articles повертає валідний JSON, а /admin/article і /article продовжують працювати як раніше.
Areas/{AreaName}/Controllers/ + Areas/{AreaName}/Views/. Кожна Area може мати власні _ViewStart, _ViewImports та Layout[Area("Name")] — обов'язковий атрибут на кожному Controller у Area. Без нього — Controller вважається глобальнимMapAreaControllerRoute — реєстрація маршруту для конкретної Area. Завжди перед MapDefaultControllerRoute{area:exists} — route constraint для єдиного шаблону що охоплює всі Areasasp-area — атрибут Tag Helpers для cross-area навігації. При переході між Areas — завжди вказувати явноAreas/{Area}/Views/{Controller}/ → Areas/{Area}/Views/Shared/ → Views/Shared/У наступній статті — View Components: повторювані незалежні блоки UI з власною логікою, що є альтернативою Partial Views для складних компонентів з DI-залежностями.
Filters: аспектно-орієнтоване програмування в MVC
Filters в ASP.NET Core MVC: filter pipeline (Authorization → Resource → Action → Exception → Result), п'ять типів фільтрів, async-варіанти, [ServiceFilter] та [TypeFilter], global filters через MvcOptions. Демо: ExecutionTimeFilter, MaintenanceModeFilter, AuditLogFilter.
View Components: повторювані незалежні блоки UI
View Components в ASP.NET Core MVC: базовий клас ViewComponent, метод InvokeAsync, структура папок Views/Shared/Components. Порівняння з Partial Views та Tag Helpers. DI у View Component. Демо: ShoppingCartViewComponent, NotificationBellViewComponent, BreadcrumbViewComponent.