Ми пройшли 15 статей — від фундаментального патерну MVC до просунутих концептів: Areas, Filters, View Components, Display/Editor Templates, FluentValidation, HTMX та Localization. Час зібрати все це разом.
У цій статті ми будуємо Blog Platform — повноцінний застосунок, у якому кожен концепт курсу знаходить практичне застосування. Це не іграшковий приклад: архітектура та підходи відображають реальні production-рішення.
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 ← лічильник коментарів
Дві Area з принципово різними Layout та цільовою аудиторією.
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));
}
}
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);
}
}
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) { }
}
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);
}
}
}
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
}
}
@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>
@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>
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; }
}
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 символів");
});
}
}
@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>
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);
}
}
@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>
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);
}
}
<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>
<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>
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 | Уся архітектура |
| 02 | Controllers та Actions | Всі controller файли |
| 03 | Routing | MapAreaControllerRoute у Program.cs |
| 04 | Model Binding | ArticleDto, CreateCommentDto параметри Actions |
| 05 | Views, ViewData, TempData | TempData["Success"], ViewData["Title"] |
| 06 | Filters | AdminAuthorizationFilter, AuditLogFilter, ExecutionTimeFilter |
| 07 | Areas | Areas/Admin/ та Areas/Public/ |
| 08 | View Components | CategoryMenuViewComponent, RecentArticlesViewComponent, CommentCountViewComponent |
| 09 | Display Templates | ArticleCard.cshtml у DisplayTemplates/ |
| 10 | Validation Advanced | ArticleDtoValidator з When(), CreateCommentDtoValidator |
| 11 | HTMX | Live-search, додавання коментарів, HX-Trigger для лічильника |
| 12 | HTMX у MVC | PartialView як відповідь, HX-Request виявлення, AntiForgery |
| 13 | File Upload | CoverImageService, magic bytes перевірка |
| 14 | Localization | .resx файли, IViewLocalizer, CookieRequestCultureProvider |
Завдання 1.1. Додайте до AdminArea новий Controller — TagController з операціями:
Index — список усіх тегів (назва + кількість статей)Create — форма створення тегу з FluentValidation (назва 2–30 символів, унікальність через MustAsync)Delete(int id) — видалення тегу з підтвердженням через hx-confirmЗавдання 1.2. Додайте RecentArticlesViewComponent що відображає у сайдбарі 5 останніх публічних статей з обкладинками. Додайте кешування через <cache expires-after="@TimeSpan.FromMinutes(5)"> у View.
Завдання 2.1. Реалізуйте Admin Dashboard з реальними метриками:
<canvas> або ASCII-art у <pre>)Завдання 2.2. Реалізуйте модерацію коментарів у Admin Area:
Areas/Admin/Controllers/CommentController.Index — список усіх коментарів з фільтрацією за статусомhx-patch="/admin/comment/5/approve" → оновлює рядок таблиці через outerHTMLAuditLogFilter повинен логувати кожен approve/reject з usernameЗавдання 3.1. Повна система авторства: кожна стаття має AuthorId. Впровадьте IAuthorService з методами GetProfileAsync(int id) та GetArticlesAsync(int authorId). Додайте:
Areas/Public/Controllers/AuthorController.Profile(int id) — публічна сторінка автораAuthorCard — аватар + ім'я + кількість статей<vc:author-articles author-id="@article.AuthorId"> у деталях статті — список інших статей автора (View Component з DI)UserProfileController з валідацією magic bytesВи пройшли повний шлях від теорії до практики. Ось що тепер знаходиться у вашому арсеналі:
Архітектура:
Rendering:
Дані та валідація:
Інтерактивність:
Файли та міжнародність:
ASP.NET Core MVC — зрілий, потужний і прагматичний фреймворк. Використовуйте його сильні сторони: строгу типізацію, Filter Pipeline, Areas та першокласну підтримку Razor Views. У комбінації з HTMX він дозволяє будувати інтерактивні застосунки без тягаря JavaScript фреймворків.
Успіхів у вашому першому production MVC-проєкті!
Глобалізація та Локалізація MVC
Globalization та Localization в ASP.NET Core MVC: IStringLocalizer<T>, IHtmlLocalizer<T>, IViewLocalizer, ресурсні файли .resx, RequestLocalizationMiddleware, визначення культури через URL-сегмент, cookie та Accept-Language. Демо: перемикач мови uk-UA/en-US/pl-PL у навбарі з локалізованими помилками валідації.
План курсу: ASP.NET Core MVC