ASP.NET Core MVC

Підсумковий проєкт: Блог-платформа

Наскрізний проєкт курсу ASP.NET Core MVC — Blog-платформа з двома Areas (Public + Admin), Filters (авторизація, аудит), View Components (кошик коментарів, категорії), Display Templates (Article Card), FluentValidation, HTMX-коментарі та live-search, завантаження обкладинок статей, локалізація uk-UA/en-US.

Підсумковий проєкт: Блог-платформа

Ми пройшли 15 статей — від фундаментального патерну MVC до просунутих концептів: Areas, Filters, View Components, Display/Editor Templates, FluentValidation, HTMX та Localization. Час зібрати все це разом.

У цій статті ми будуємо Blog Platform — повноцінний застосунок, у якому кожен концепт курсу знаходить практичне застосування. Це не іграшковий приклад: архітектура та підходи відображають реальні production-рішення.

Мета фінального проєкту — інтеграція, а не повне кодування. Ми фокусуємось на точках з'єднання: де саме і як кожен механізм застосовується у цілісній системі. Повний вихідний код доступний на GitHub (посилання у кінці статті).

Архітектура проєкту

BlogPlatform/
├── Areas/
│   ├── Admin/                          ← Area 1: адміністрування
│   │   ├── Controllers/
│   │   │   ├── DashboardController.cs  ← статистика
│   │   │   ├── ArticleController.cs    ← CRUD статей
│   │   │   └── CommentController.cs    ← модерація коментарів
│   │   └── Views/
│   │       ├── Dashboard/
│   │       ├── Article/
│   │       └── Shared/
│   │           └── _AdminLayout.cshtml
│   └── Public/                         ← Area 2: читачі
│       ├── Controllers/
│       │   ├── ArticleController.cs    ← список та деталі
│       │   └── CommentController.cs    ← додавання коментарів (HTMX)
│       └── Views/
│           ├── Article/
│           └── Shared/
│               └── _PublicLayout.cshtml
├── Attributes/
│   ├── AllowedFileExtensionsAttribute.cs
│   └── MaxFileSizeAttribute.cs
├── Filters/
│   ├── AdminAuthorizationFilter.cs     ← захист Admin Area
│   ├── ExecutionTimeFilter.cs          ← моніторинг
│   └── AuditLogFilter.cs              ← аудит дій в Admin
├── Models/
│   ├── Article.cs
│   ├── Comment.cs
│   └── ViewModels/
│       ├── ArticleDto.cs
│       └── CreateCommentDto.cs
├── Resources/                          ← .resx файли
│   ├── SharedResource.uk-UA.resx
│   └── SharedResource.en-US.resx
├── Services/
│   ├── IArticleService.cs
│   ├── ICommentService.cs
│   └── ICoverImageService.cs
└── ViewComponents/
    ├── CategoryMenuViewComponent.cs     ← меню категорій в сайдбарі
    ├── RecentArticlesViewComponent.cs   ← блок «Останні статті»
    └── CommentCountViewComponent.cs     ← лічильник коментарів

Концепція 1: Areas — розділення Admin та Public

Дві Area з принципово різними Layout та цільовою аудиторією.

Areas/Admin/Controllers/ArticleController.cs
using BlogPlatform.Filters;
using BlogPlatform.Services;
using Microsoft.AspNetCore.Mvc;

namespace BlogPlatform.Areas.Admin.Controllers;

[Area("Admin")]
[ServiceFilter(typeof(AdminAuthorizationFilter))] // ← Concept 3: Filter захищає всю Area
[ServiceFilter(typeof(AuditLogFilter))]            // ← Concept 3: аудит кожної дії
public class ArticleController : Controller
{
    private readonly IArticleService _articles;
    private readonly ICoverImageService _covers;
    private readonly IStringLocalizer<ArticleController> _localizer;

    public ArticleController(
        IArticleService articles,
        ICoverImageService covers,
        IStringLocalizer<ArticleController> localizer) // ← Concept 8: Localization
    {
        _articles = articles;
        _covers = covers;
        _localizer = localizer;
    }

    // GET /admin/article
    public async Task<IActionResult> Index()
    {
        var articles = await _articles.GetAllAsync();
        ViewData["Title"] = _localizer["ManageArticles"];
        return View(articles);
    }

    // GET /admin/article/create
    [HttpGet]
    public IActionResult Create() => View(new ArticleDto());

    // POST /admin/article/create
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(ArticleDto dto)
    {
        // FluentValidation виконується автоматично → Concept 6: Validation
        if (!ModelState.IsValid) return View(dto);

        // Зберігаємо обкладинку → Concept 7: File Upload
        string? coverUrl = null;
        if (dto.CoverImage is not null)
        {
            if (!await FileValidator.IsValidImageAsync(dto.CoverImage))
            {
                ModelState.AddModelError(nameof(dto.CoverImage), "Файл не є зображенням");
                return View(dto);
            }
            coverUrl = await _covers.SaveAsync(dto.CoverImage);
        }

        await _articles.CreateAsync(dto, coverUrl);

        TempData["Success"] = _localizer["ArticleCreated", dto.Title];
        return RedirectToAction(nameof(Index));
    }
}
Areas/Public/Controllers/ArticleController.cs
using Microsoft.AspNetCore.Mvc;

namespace BlogPlatform.Areas.Public.Controllers;

[Area("Public")]
public class ArticleController : Controller
{
    private readonly IArticleService _articles;

    public ArticleController(IArticleService articles)
    {
        _articles = articles;
    }

    // GET / — публічна сторінка
    public async Task<IActionResult> Index(string? q, string? category)
    {
        var articles = await _articles.GetPublishedAsync(q, category);

        // Якщо HTMX-запит (live-search, фільтр) → тільки список
        if (Request.Headers.ContainsKey("HX-Request"))
            return PartialView("_ArticleList", articles);

        return View(articles);
    }

    // GET /article/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);
    }
}

Концепція 2: Filters — наскрізна логіка

Filters/AdminAuthorizationFilter.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace BlogPlatform.Filters;

// Resource Filter: перевіряє авторизацію до будь-якої логіки Action
public class AdminAuthorizationFilter : IResourceFilter
{
    private readonly ILogger<AdminAuthorizationFilter> _logger;

    public AdminAuthorizationFilter(ILogger<AdminAuthorizationFilter> logger)
    {
        _logger = logger;
    }

    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        // У реальному проєкті — перевірка Claims/Roles
        // Тут — спрощена перевірка query string для демонстрації
        if (!context.HttpContext.User.Identity?.IsAuthenticated ?? true)
        {
            _logger.LogWarning("Несанкціонована спроба доступу до Admin Area від {IP}",
                context.HttpContext.Connection.RemoteIpAddress);

            // Short-circuit: redirect без виконання Action
            context.Result = new RedirectToRouteResult(new RouteValueDictionary
            {
                ["area"] = "Public",
                ["controller"] = "Article",
                ["action"] = "Index"
            });
        }
    }

    public void OnResourceExecuted(ResourceExecutedContext context) { }
}
Filters/AuditLogFilter.cs
using Microsoft.AspNetCore.Mvc.Filters;

namespace BlogPlatform.Filters;

// Action Filter: логує кожну Admin-дію
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuditLogFilter : Attribute, IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var logger = context.HttpContext.RequestServices
            .GetRequiredService<ILogger<AuditLogFilter>>();

        var user = context.HttpContext.User.Identity?.Name ?? "anonymous";
        var action = $"{context.ActionDescriptor.DisplayName}";
        var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString();

        logger.LogInformation("[AUDIT] {User} @ {IP} → {Action}", user, ip, action);

        var executed = await next(); // виконати Action

        if (executed.Exception is not null)
        {
            logger.LogError("[AUDIT ERROR] {User} → {Action}: {Error}",
                user, action, executed.Exception.Message);
        }
    }
}

Концепція 3: View Components — незалежні блоки UI

ViewComponents/CategoryMenuViewComponent.cs
using Microsoft.AspNetCore.Mvc;

namespace BlogPlatform.ViewComponents;

// Викликається у _PublicLayout.cshtml: <vc:category-menu></vc:category-menu>
public class CategoryMenuViewComponent : ViewComponent
{
    private readonly IArticleService _articles;

    public CategoryMenuViewComponent(IArticleService articles)
    {
        _articles = articles;
    }

    public async Task<IViewComponentResult> InvokeAsync()
    {
        var categories = await _articles.GetCategoriesWithCountAsync();
        return View(categories); // → Views/Shared/Components/CategoryMenu/Default.cshtml
    }
}
Views/Shared/Components/CategoryMenu/Default.cshtml
@model IReadOnlyList<(string Name, int Count)>
@{
    var currentCategory = ViewContext.RouteData.Values["category"]?.ToString();
}

<div class="card mb-4">
    <div class="card-header fw-bold">Категорії</div>
    <div class="list-group list-group-flush">
        <a href="/"
           class="list-group-item list-group-item-action @(currentCategory is null ? "active" : "")">
            Всі статті
        </a>
        @foreach (var (name, count) in Model)
        {
            <a href="/?category=@name"
               class="list-group-item list-group-item-action @(name == currentCategory ? "active" : "")">
                @name
                <span class="badge bg-secondary float-end">@count</span>
            </a>
        }
    </div>
</div>

Концепція 4: Display Template — ArticleCard

Views/Shared/DisplayTemplates/ArticleCard.cshtml
@model BlogPlatform.Models.Article

<article class="card h-100 shadow-sm">
    @if (!string.IsNullOrEmpty(Model.CoverUrl))
    {
        <img src="@Model.CoverUrl" alt="@Model.Title"
             class="card-img-top object-fit-cover" style="height:200px">
    }
    <div class="card-body">
        <div class="d-flex gap-2 mb-2">
            <span class="badge bg-primary">@Model.Category</span>
            <small class="text-muted">@Model.PublishedAt.ToString("dd MMM yyyy")</small>
        </div>
        <h5 class="card-title">@Model.Title</h5>
        <p class="card-text text-muted">
            @(Model.Summary?.Length > 120 ? Model.Summary[..120] + "…" : Model.Summary)
        </p>
    </div>
    <div class="card-footer bg-transparent border-top-0">
        <a asp-area="Public"
           asp-controller="Article"
           asp-action="Details"
           asp-route-id="@Model.Id"
           class="btn btn-outline-primary btn-sm">Читати далі</a>

        @* View Component: лічильник коментарів *@
        <span class="float-end text-muted small mt-2">
            <vc:comment-count article-id="@Model.Id"></vc:comment-count>
        </span>
    </div>
</article>

Концепція 5: FluentValidation — ArticleDto

Models/ViewModels/ArticleDto.cs
namespace BlogPlatform.Models.ViewModels;

public class ArticleDto
{
    public string Title { get; set; } = "";
    public string? Summary { get; set; }
    public string Content { get; set; } = "";
    public string Category { get; set; } = "";
    public IFormFile? CoverImage { get; set; }
    public bool IsPublished { get; set; }
}
Validators/ArticleDtoValidator.cs
using FluentValidation;
using BlogPlatform.Models.ViewModels;

namespace BlogPlatform.Validators;

public class ArticleDtoValidator : AbstractValidator<ArticleDto>
{
    public ArticleDtoValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Заголовок обов'язковий")
            .Length(5, 200).WithMessage("Від 5 до 200 символів")
            .Matches(@"^[^<>]+$").WithMessage("Заголовок не може містити HTML");

        RuleFor(x => x.Content)
            .NotEmpty().WithMessage("Зміст обов'язковий")
            .MinimumLength(100).WithMessage("Мінімум 100 символів");

        RuleFor(x => x.Category)
            .NotEmpty().WithMessage("Оберіть категорію")
            .Must(c => new[] { "Tech", "Design", "Business", "Life" }.Contains(c))
            .WithMessage("Невідома категорія");

        // Обкладинка — необов'язкова, але якщо є — валідація
        When(x => x.CoverImage is not null, () =>
        {
            RuleFor(x => x.CoverImage!.Length)
                .LessThanOrEqualTo(5 * 1024 * 1024)
                .WithMessage("Обкладинка не більше 5 МБ");

            RuleFor(x => x.CoverImage!.FileName)
                .Matches(@"\.(jpg|jpeg|png|webp)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
                .WithMessage("Лише JPEG, PNG або WebP");
        });

        // Якщо публікується — резюме обов'язкове
        When(x => x.IsPublished, () =>
        {
            RuleFor(x => x.Summary)
                .NotEmpty().WithMessage("Для публікації вкажіть резюме")
                .MaximumLength(300).WithMessage("Резюме до 300 символів");
        });
    }
}

Live-search у Public Area

Areas/Public/Views/Article/Index.cshtml
@model IReadOnlyList<BlogPlatform.Models.Article>
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<div class="row g-4">
    @* Сайдбар *@
    <div class="col-md-3">
        <vc:category-menu></vc:category-menu>
        <vc:recent-articles></vc:recent-articles>
    </div>

    @* Основний контент *@
    <div class="col-md-9">
        <div class="d-flex justify-content-between align-items-center mb-3">
            <h1>@Localizer["Articles"]</h1>
            @* Live-search: hx-trigger="input delay:300ms" → Partial View *@
            <input type="search"
                   name="q"
                   placeholder="@Localizer["SearchPlaceholder"]"
                   class="form-control w-auto"
                   hx-get="@Url.Action("Index", "Article", new { area = "Public" })"
                   hx-trigger="input changed delay:300ms, search"
                   hx-target="#articles-grid"
                   hx-indicator="#search-spinner">
            <span id="search-spinner" class="htmx-indicator ms-2">
                <div class="spinner-border spinner-border-sm text-primary"></div>
            </span>
        </div>

        <div id="articles-grid" class="row row-cols-1 row-cols-md-2 g-4">
            @foreach (var article in Model)
            {
                <div class="col">
                    @* Display Template ArticleCard *@
                    @Html.DisplayFor(_ => article, "ArticleCard")
                </div>
            }
        </div>
    </div>
</div>

HTMX-коментарі зі real-time оновленням

Areas/Public/Controllers/CommentController.cs
using BlogPlatform.Models.ViewModels;
using BlogPlatform.Services;
using Microsoft.AspNetCore.Mvc;

namespace BlogPlatform.Areas.Public.Controllers;

[Area("Public")]
public class CommentController : Controller
{
    private readonly ICommentService _comments;

    public CommentController(ICommentService comments)
    {
        _comments = comments;
    }

    // POST /Public/Comment/Add — HTMX запит
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Add(CreateCommentDto dto)
    {
        if (!ModelState.IsValid)
        {
            Response.StatusCode = 422;
            Response.Headers["HX-Reswap"] = "none"; // не міняти DOM при помилці
            return PartialView("_CommentError", ModelState);
        }

        var comment = await _comments.AddAsync(dto);

        // HX-Trigger: оновити лічильник коментарів на картці статті
        Response.Headers["HX-Trigger"] = $"{{\"commentAdded\": {{\"articleId\": {dto.ArticleId}}}}}";

        return PartialView("_CommentItem", comment);
    }
}
Areas/Public/Views/Article/Details.cshtml (фрагмент)
@model BlogPlatform.Models.Article

<section id="comments">
    <h3>Коментарі</h3>

    @* Форма коментаря через HTMX *@
    <form hx-post="@Url.Action("Add", "Comment", new { area = "Public" })"
          hx-target="#comments-list"
          hx-swap="beforeend"
          hx-on::after-request="if(event.detail.successful) this.reset()"
          class="mb-4">
        @Html.AntiForgeryToken()
        <input type="hidden" name="ArticleId" value="@Model.Id">

        <div class="mb-3">
            <label class="form-label">Ваш коментар</label>
            <textarea name="Text" class="form-control" rows="3"
                      placeholder="Поділіться думками..." required></textarea>
        </div>
        <button type="submit" class="btn btn-primary">
            <span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
            Додати коментар
        </button>
    </form>

    @* Список коментарів *@
    <div id="comments-list">
        @foreach (var comment in Model.Comments)
        {
            <partial name="_CommentItem" model="comment"/>
        }
    </div>
</section>

Концепція 7: File Upload — обкладинки статей

Services/CoverImageService.cs
using Microsoft.AspNetCore.Hosting;

namespace BlogPlatform.Services;

public class CoverImageService : ICoverImageService
{
    private readonly string _uploadsPath;

    public CoverImageService(IWebHostEnvironment env)
    {
        _uploadsPath = Path.Combine(env.WebRootPath, "uploads", "covers");
        Directory.CreateDirectory(_uploadsPath);
    }

    public async Task<string> SaveAsync(IFormFile file)
    {
        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
        var fileName = $"{Guid.NewGuid():N}{ext}";
        var path = Path.Combine(_uploadsPath, fileName);

        await using var stream = File.Create(path);
        await file.CopyToAsync(stream);

        return $"/uploads/covers/{fileName}";
    }

    public void Delete(string coverUrl)
    {
        if (string.IsNullOrEmpty(coverUrl)) return;
        var fileName = Path.GetFileName(coverUrl);
        var path = Path.Combine(_uploadsPath, fileName);
        if (File.Exists(path)) File.Delete(path);
    }
}

Концепція 8: Localization — двомовний інтерфейс

Resources/SharedResource.uk-UA.resx
<data name="Articles"><value>Статті</value></data>
<data name="SearchPlaceholder"><value>Пошук статей...</value></data>
<data name="ManageArticles"><value>Управління статтями</value></data>
<data name="ArticleCreated"><value>Статтю «{0}» успішно створено</value></data>
<data name="Save"><value>Зберегти</value></data>
<data name="Cancel"><value>Скасувати</value></data>
<data name="Publish"><value>Опублікувати</value></data>
<data name="Draft"><value>Чернетка</value></data>
Resources/SharedResource.en-US.resx
<data name="Articles"><value>Articles</value></data>
<data name="SearchPlaceholder"><value>Search articles...</value></data>
<data name="ManageArticles"><value>Manage Articles</value></data>
<data name="ArticleCreated"><value>Article "{0}" created successfully</value></data>
<data name="Save"><value>Save</value></data>
<data name="Cancel"><value>Cancel</value></data>
<data name="Publish"><value>Publish</value></data>
<data name="Draft"><value>Draft</value></data>

Program.cs: реєстрація всього

Program.cs
using BlogPlatform.Filters;
using BlogPlatform.Resources;
using BlogPlatform.Services;
using BlogPlatform.Validators;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Localization;
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);

// ── MVC та локалізація ────────────────────────────────────────────
builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");

builder.Services.AddControllersWithViews()
    .AddViewLocalization()
    .AddDataAnnotationsLocalization(o =>
    {
        o.DataAnnotationLocalizerProvider = (_, factory) =>
            factory.Create(typeof(SharedResource));
    });

// ── FluentValidation ─────────────────────────────────────────────
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<ArticleDtoValidator>();

// ── Session ──────────────────────────────────────────────────────
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(o =>
{
    o.IdleTimeout = TimeSpan.FromHours(2);
    o.Cookie.HttpOnly = true;
    o.Cookie.IsEssential = true;
});

// ── Services ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IArticleService, InMemoryArticleService>();
builder.Services.AddSingleton<ICommentService, InMemoryCommentService>();
builder.Services.AddScoped<ICoverImageService, CoverImageService>();

// ── Filters (для ServiceFilter) ───────────────────────────────────
builder.Services.AddScoped<AdminAuthorizationFilter>();
builder.Services.AddScoped<AuditLogFilter>();
builder.Services.AddScoped<ExecutionTimeFilter>();

// ── Локалізація ───────────────────────────────────────────────────
var supportedCultures = new[] { new CultureInfo("uk-UA"), new CultureInfo("en-US") };
builder.Services.Configure<RequestLocalizationOptions>(o =>
{
    o.DefaultRequestCulture = new RequestCulture("uk-UA");
    o.SupportedCultures = supportedCultures;
    o.SupportedUICultures = supportedCultures;
    o.RequestCultureProviders =
    [
        new CookieRequestCultureProvider(),
        new AcceptLanguageHeaderRequestCultureProvider()
    ];
});

// ── Глобальні фільтри (для всього застосунку) ─────────────────────
builder.Services.AddControllersWithViews(o =>
{
    o.Filters.Add<ExecutionTimeFilter>(); // моніторинг часу на всіх Actions
});

var app = builder.Build();

// ── Middleware pipeline ───────────────────────────────────────────
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseRequestLocalization();
app.UseStaticFiles();
app.UseRouting();
app.UseSession();
app.UseAuthorization();

// Admin Area: /admin/...
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?}"
);

app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

Карта концептів у проєкті

#КонцептДе застосовано
01Патерн MVCУся архітектура
02Controllers та ActionsВсі controller файли
03RoutingMapAreaControllerRoute у Program.cs
04Model BindingArticleDto, CreateCommentDto параметри Actions
05Views, ViewData, TempDataTempData["Success"], ViewData["Title"]
06FiltersAdminAuthorizationFilter, AuditLogFilter, ExecutionTimeFilter
07AreasAreas/Admin/ та Areas/Public/
08View ComponentsCategoryMenuViewComponent, RecentArticlesViewComponent, CommentCountViewComponent
09Display TemplatesArticleCard.cshtml у DisplayTemplates/
10Validation AdvancedArticleDtoValidator з When(), CreateCommentDtoValidator
11HTMXLive-search, додавання коментарів, HX-Trigger для лічильника
12HTMX у MVCPartialView як відповідь, HX-Request виявлення, AntiForgery
13File UploadCoverImageService, magic bytes перевірка
14Localization.resx файли, IViewLocalizer, CookieRequestCultureProvider

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

Рівень 1 — Розширення базового проєкту

Завдання 1.1. Додайте до AdminArea новий ControllerTagController з операціями:

  • Index — список усіх тегів (назва + кількість статей)
  • Create — форма створення тегу з FluentValidation (назва 2–30 символів, унікальність через MustAsync)
  • Delete(int id) — видалення тегу з підтвердженням через hx-confirm

Завдання 1.2. Додайте RecentArticlesViewComponent що відображає у сайдбарі 5 останніх публічних статей з обкладинками. Додайте кешування через <cache expires-after="@TimeSpan.FromMinutes(5)"> у View.

Рівень 2 — Інтеграція нових концептів

Завдання 2.1. Реалізуйте Admin Dashboard з реальними метриками:

  • Кількість статей (всього / опубліковано / чернетки)
  • Кількість коментарів (всього / очікує модерації)
  • Топ-5 статей за кількістю коментарів
  • Графік статей за останні 30 днів (через простий <canvas> або ASCII-art у <pre>)

Завдання 2.2. Реалізуйте модерацію коментарів у Admin Area:

  • Areas/Admin/Controllers/CommentController.Index — список усіх коментарів з фільтрацією за статусом
  • Inline approve/reject через HTMX: hx-patch="/admin/comment/5/approve" → оновлює рядок таблиці через outerHTML
  • AuditLogFilter повинен логувати кожен approve/reject з username

Рівень 3 — Full-stack розширення

Завдання 3.1. Повна система авторства: кожна стаття має AuthorId. Впровадьте IAuthorService з методами GetProfileAsync(int id) та GetArticlesAsync(int authorId). Додайте:

  • Areas/Public/Controllers/AuthorController.Profile(int id) — публічна сторінка автора
  • Display Template AuthorCard — аватар + ім'я + кількість статей
  • <vc:author-articles author-id="@article.AuthorId"> у деталях статті — список інших статей автора (View Component з DI)
  • Завантаження аватара через UserProfileController з валідацією magic bytes

Підсумок курсу

Ви пройшли повний шлях від теорії до практики. Ось що тепер знаходиться у вашому арсеналі:

Архітектура:

  • Патерн MVC: розділення Controller / View / Model
  • Areas для масштабування великих застосунків
  • Filter Pipeline для наскрізної логіки

Rendering:

  • Razor Views, ViewData, TempData, ViewModel
  • View Components — незалежні блоки з DI
  • Display/Editor Templates — повторюваний рендеринг типів

Дані та валідація:

  • Model Binding: форми, query string, route, JSON body
  • IValidatableObject для крос-field правил
  • FluentValidation для тестованих складних правил
  • Remote validation через Remote

Інтерактивність:

  • HTMX: live-search, lazy loading, inline edit, real-time через SSE
  • AntiForgery з HTMX, HX-Response headers

Файли та міжнародність:

  • IFormFile, PhysicalFileResult, streaming
  • IStringLocalizer, IViewLocalizer, .resx файли
  • RequestLocalizationMiddleware

ASP.NET Core MVC — зрілий, потужний і прагматичний фреймворк. Використовуйте його сильні сторони: строгу типізацію, Filter Pipeline, Areas та першокласну підтримку Razor Views. У комбінації з HTMX він дозволяє будувати інтерактивні застосунки без тягаря JavaScript фреймворків.

Успіхів у вашому першому production MVC-проєкті!