ASP.NET Core MVC

Areas: структурування великих застосунків

Areas в ASP.NET Core MVC: структура папок, атрибут [Area], MapAreaControllerRoute, cross-area посилання через asp-area, конфлікти маршрутів. Демо: застосунок з Areas/Admin та Areas/Public — Dashboard, ArticleManager, ArticleList.

Areas: структурування великих застосунків

Уявіть: ваш застосунок виріс. У ньому є публічна частина для читачів, адміністративна панель для редакторів і API для мобільного застосунку. Усі три частини мають власні Controllers, Views і моделі — і їх вже не один десяток. Папка Controllers/ перетворилася на хаос: HomeController, ArticleController, AdminDashboardController, AdminArticleController, AdminUserController...

Саме для цього сценарію в ASP.NET Core MVC існують Areas (Області). Area — це спосіб розбити великий застосунок на логічно ізольовані модулі, кожен зі своїми Controllers, Views та моделями, але в межах одного застосунку. На відміну від мікросервісів, Area — це не окремий процес і не окрема збірка: все залишається в одному проєкті, але організовано набагато чистіше.

Areas — концепція виключно MVC. У Razor Pages аналогом є вкладені папки у Pages/, але без явного поняття «area». Minimal API Areas не має взагалі.

Що таке Area?

Area — це іменована підструктура всередині MVC-застосунку. Технічно кожна Area:

  1. Має власну папку з конвенційною структурою Areas/{AreaName}/Controllers/ та Areas/{AreaName}/Views/
  2. Реєструє власний маршрут через MapAreaControllerRoute
  3. Кожен Controller в Area позначається атрибутом [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 окремо.


Крок 1: Атрибут [Area]

Кожен Controller, що належить до Area, обов'язково позначається атрибутом [Area("AreaName")]. Без цього атрибуту Controller вважається частиною «кореневого» застосунку, навіть якщо фізично знаходиться у папці Areas/.

Areas/Admin/Controllers/DashboardController.cs
using Microsoft.AspNetCore.Mvc;

namespace BlogApp.Areas.Admin.Controllers;

[Area("Admin")] // ← обов'язково: приналежність до Area
public class DashboardController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}
Areas/Public/Controllers/ArticleController.cs
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. Це не обов'язково, але робить код зрозумілим.


Крок 2: Реєстрація маршрутів Area

Стандартний MapDefaultControllerRoute не знає про Areas. Для кожної Area потрібно явно зареєструвати маршрут через MapAreaControllerRoute:

Program.cs
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();
Порядок реєстрації маршрутів критичний. Маршрути перевіряються зверху донизу, і перший що збігається — виграє. Завжди реєструйте більш специфічні маршрути (з явним префіксом area) перед загальним MapDefaultControllerRoute.

Альтернатива для досвідчених — один шаблон маршруту з токеном {area}:

Program.cs — альтернатива
// Єдиний маршрут що охоплює всі Areas
app.MapControllerRoute(
    name: "areas",
    pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"
);
app.MapDefaultControllerRoute(); // для Controllers без Area

Токен {area:exists} — це route constraint: маршрут спрацює лише якщо існує Area з такою назвою. Без цього обмеження {area} захоплював би будь-який сегмент URL.


Крок 3: _ViewImports.cshtml та _ViewStart.cshtml для кожної Area

Кожна Area може мати власні _ViewImports.cshtml та _ViewStart.cshtml:

Areas/Admin/Views/_ViewImports.cshtml
@using BlogApp.Areas.Admin.Controllers
@using BlogApp.Areas.Admin.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Areas/Admin/Views/_ViewStart.cshtml
@{
    // Admin Area використовує власний Layout
    Layout = "_AdminLayout";
}
Areas/Public/Views/_ViewStart.cshtml
@{
    // Public Area використовує стандартний Layout
    Layout = "~/Views/Shared/_Layout.cshtml";
}

Це дозволяє Admin Area мати зовсім інший дизайн (темна адмін-панель) ніж Public Area (публічний сайт), без жодного if у _Layout.


Cross-Area посилання: атрибут 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>
Якщо ви всередині Area і хочете залишитися в ній — asp-area можна не вказувати, він успадковується з поточного маршруту. Якщо ж хочете вийти за межі Area — обов'язково вкажіть asp-area="" (порожній рядок для кореневих Controllers) або ім'я іншої Area.

Пошук View у контексті 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.

Areas/Admin/Controllers/ArticleController.cs
[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");
    }
}

Демо-проєкт: Blog Platform із двома Areas

Побудуємо мінімальний, але повністю функціональний застосунок із двома Area: Admin (управління статтями) та Public (читання статей).

Крок 1: Модель та сервіс

Models/Article.cs
namespace BlogApp.Models;

public record Article(
    int Id,
    string Title,
    string Content,
    string Author,
    DateTime PublishedAt,
    bool IsPublished
);
Services/IArticleService.cs
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);
}
Services/InMemoryArticleService.cs
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;
    }
}

Крок 2: Admin Area — Controllers

Areas/Admin/Controllers/DashboardController.cs
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();
    }
}
Areas/Admin/Controllers/ArticleController.cs
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));
    }
}

Крок 3: Public Area — Controllers

Areas/Public/Controllers/ArticleController.cs
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);
    }
}

Крок 4: Views адміністративної Area

Areas/Admin/Views/Dashboard/Index.cshtml
@{ 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>
Areas/Admin/Views/Article/Index.cshtml
@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>

Крок 5: Views публічної Area

Areas/Public/Views/Article/Index.cshtml
@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>

Крок 6: Фінальний Program.cs

Program.cs
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 /adminAreas/Admin/Controllers/DashboardController.Index — адмін-дашборд
  • GET /admin/articleAreas/Admin/Controllers/ArticleController.Index — управління статтями
  • POST /admin/article/create → створення нової статті
  • GET /article/details/1Areas/Public/Controllers/ArticleController.Details(1) — стаття

Типові помилки та їх усунення

Помилка 1: «AmbiguousMatchException» — конфлікт маршрутів

Симптом: AmbiguousMatchException: The request matched multiple endpoints.

Причина: У застосунку є два Controllers з однаковою назвою у різних Areas, але маршрут не дозволяє ASP.NET Core розрізнити їх.

Рішення: Переконайтеся що:

  1. Кожен Area-Controller має атрибут [Area("...")]
  2. Кожна Area має зареєстрований маршрут через MapAreaControllerRoute
  3. Порядок маршрутів правильний (MapAreaControllerRoute до MapDefaultControllerRoute)

Помилка 2: View не знайдено («No view found»)

Симптом: InvalidOperationException: The view 'Index' was not found.

Причина: View розміщена не там, де її шукає MVC.

Рішення: Перевірте структуру папок:

Areas/{AreaName}/Views/{ControllerName}/{ActionName}.cshtml

Назва папки {ControllerName} — це ім'я класу без суфікса Controller. Якщо клас ArticleController — папка має називатися Article.

Помилка 3: asp-area не враховується у формах

Симптом: Форма з asp-area="Admin" генерує URL без /admin префікса.

Причина: View знаходиться поза Area, і поточний контекст маршруту не містить значення area.

Рішення: Завжди явно вказуйте asp-area у формах і посиланнях, коли переходите між Areas. Ніколи не покладайтеся на неявне успадкування area зі стрічки маршруту у cross-area контексті.


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

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

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

Завдання 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 — Архітектура

Завдання 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 продовжують працювати як раніше.


Резюме

  • Area — іменований модуль, що ізолює Controllers та Views у більшому MVC-застосунку. Унікальна для MVC, відсутня у Razor Pages та Minimal API
  • Структура: Areas/{AreaName}/Controllers/ + Areas/{AreaName}/Views/. Кожна Area може мати власні _ViewStart, _ViewImports та Layout
  • [Area("Name")] — обов'язковий атрибут на кожному Controller у Area. Без нього — Controller вважається глобальним
  • MapAreaControllerRoute — реєстрація маршруту для конкретної Area. Завжди перед MapDefaultControllerRoute
  • {area:exists} — route constraint для єдиного шаблону що охоплює всі Areas
  • asp-area — атрибут Tag Helpers для cross-area навігації. При переході між Areas — завжди вказувати явно
  • Порядок пошуку View: Areas/{Area}/Views/{Controller}/Areas/{Area}/Views/Shared/Views/Shared/

У наступній статті — View Components: повторювані незалежні блоки UI з власною логікою, що є альтернативою Partial Views для складних компонентів з DI-залежностями.