Коли HTTP-запит надходить до вашого Action-методу, він містить безліч даних: сегменти URL, query string параметри, тіло форми, JSON, заголовки. Model Binding — це механізм ASP.NET Core що автоматично перетворює всі ці HTTP-дані на C# об'єкти, які ви отримуєте як параметри методу.
Ключова різниця від Razor Pages: тут немає [BindProperty]. Всі вхідні дані — це параметри Action-методу.
Уявіть, якби Model Binding не існував:
// ❌ Без Model Binding — ручне парсення HTTP:
public IActionResult Search()
{
// Витягуємо все вручну з HttpContext
var query = Request.Query["q"].ToString();
var pageStr = Request.Query["page"].ToString();
var page = int.TryParse(pageStr, out var p) ? p : 1;
var minPriceStr = Request.Query["minPrice"].ToString();
decimal? minPrice = decimal.TryParse(minPriceStr, out var mp) ? mp : null;
// І це лише для query string. А якщо ще й body...
// ...
}
// ✅ З Model Binding — параметри просто з'являються:
public IActionResult Search(string? q, int page = 1, decimal? minPrice = null)
{
// page вже int, minPrice вже decimal?, q вже string?
// Model Binding зробив все за вас
}
Model Binding — це автоматичний маппер HTTP→C#.
Коли ASP.NET Core прив'язує параметр id в Details(int id), він шукає значення у такому порядку:
1. Form values (POST body: application/x-www-form-urlencoded або multipart)
2. Route values (сегменти URL: /product/details/42 → id=42)
3. Query string (URL параметри: /product/details?id=42)
[FromRoute]/[FromQuery] часто обов'язкові, у MVC Controller Model Binder автоматично шукає значення у всіх джерелах. Явні атрибути потрібні лише для уточнення або особливих випадків.public class ProductController : Controller
{
// id буде знайдено автоматично:
// - з маршруту: GET /product/details/42
// - або з query: GET /product/details?id=42
public IActionResult Details(int id) => View();
// Якщо і маршрут, і query мають "page" — форма перемагає,
// потім маршрут, потім query (Form > Route > Query)
public IActionResult List(int page = 1) => View();
}
Якщо вам потрібно точно вказати звідки брати дані:
[HttpGet("products/{category}/{id:int}")]
public IActionResult Details(
[FromRoute] string category, // лише з /products/{category}/
[FromRoute] int id) // лише з /{id}
{
return View();
}
// GET /products/electronics/42 → category="electronics", id=42
// GET /products/electronics/42?id=999 → id=42 (query ігнорується!)
public IActionResult Search(
[FromQuery] string? q, // ?q=laptop
[FromQuery(Name = "p")] int page, // ?p=2 (параметр має ім'я "p")
[FromQuery] decimal? maxPrice) // ?maxPrice=5000
{
return View();
}
// GET /product/search?q=laptop&p=2&maxPrice=5000
[HttpPost]
public IActionResult Create(
[FromForm] string title, // з POST body: title=...
[FromForm] string author) // з POST body: author=...
{
// ...
}
[HttpPost]
public IActionResult CreateFromApi([FromBody] CreateProductDto dto)
{
// dto десеріалізується з JSON тіла запиту
// Content-Type: application/json
// { "title": "...", "price": 1000 }
}
[FromBody] можна використати лише один раз на метод. Якщо потрібно кілька параметрів з JSON — зберіть їх в один DTO-клас.public IActionResult GetWithHeader(
[FromHeader(Name = "X-Api-Version")] string apiVersion,
[FromHeader(Name = "Accept-Language")] string language)
{
// Читаємо власні заголовки запиту
}
// Не потрібно ін'єктувати через конструктор якщо сервіс потрібен лише в одному Action
public IActionResult Report([FromServices] IReportService reportSvc)
{
var report = reportSvc.Generate();
return View(report);
}
Model Binding автоматично прив'язує складні об'єкти з тіла форми:
public record CreateProductDto(
string Title,
string Description,
decimal Price,
int StockCount,
string Category
);
public class ProductController : Controller
{
// POST /product/create з body:
// Title=Laptop&Description=...&Price=45000&StockCount=10&Category=Electronics
[HttpPost]
public async Task<IActionResult> Create(CreateProductDto dto)
{
// dto.Title, dto.Price тощо вже заповнені!
if (!ModelState.IsValid) return View(dto);
// ...
}
}
public record AddressDto(string Street, string City, string ZipCode);
public record OrderDto(
string CustomerName,
string Email,
AddressDto ShippingAddress // ← вкладений об'єкт
);
<!-- HTML-форма з вкладеним об'єктом -->
<input name="CustomerName" value="...">
<input name="Email" value="...">
<input name="ShippingAddress.Street" value="..."> <!-- ← крапкова нотація -->
<input name="ShippingAddress.City" value="...">
<input name="ShippingAddress.ZipCode" value="...">
[HttpPost]
public IActionResult PlaceOrder(OrderDto order)
{
// order.ShippingAddress.Street — вже заповнено
// Крапкова нотація у HTML автоматично прив'язується до вкладених властивостей
}
<!-- Масив рядків: items[0], items[1], ... -->
<input name="items[0]" value="Book">
<input name="items[1]" value="Pen">
<!-- Масив об'єктів -->
<input name="orderItems[0].ProductId" value="1">
<input name="orderItems[0].Quantity" value="2">
<input name="orderItems[1].ProductId" value="5">
<input name="orderItems[1].Quantity" value="1">
[HttpPost]
public IActionResult SaveOrder(
List<string> items, // ["Book", "Pen"]
List<OrderItemDto> orderItems) // [{ProductId=1, Qty=2}, {ProductId=5, Qty=1}]
{ ... }
Корисно коли модель вже є (наприклад, завантажена з БД) і потрібно оновити лише деякі поля:
[HttpPost]
public async Task<IActionResult> Edit(int id)
{
// 1. Завантажуємо існуючу сутність
var product = await _service.GetByIdAsync(id);
if (product is null) return NotFound();
// 2. Оновлюємо тільки дозволені поля з форми
// Захист від over-posting: вказуємо whitelist полів
var updateSucceeded = await TryUpdateModelAsync(
product,
prefix: "", // префікс форми
p => p.Title, // дозволені поля
p => p.Description,
p => p.Price
// p.CreatedAt — НЕ тут, не дозволяємо оновлювати
);
if (!updateSucceeded || !ModelState.IsValid)
return View(product);
await _service.SaveAsync(product);
return RedirectToAction(nameof(Index));
}
TryUpdateModelAsync з whitelist — це захист від over-posting атаки: коли зловмисник надсилає додаткові поля (наприклад, IsAdmin=true) у POST-запиті, сподіваючись що вони прив'яжуться до моделі.Іноді стандартний Model Binder не вміє конвертувати дані у ваш тип. Наприклад, Money — складний тип що представляє суму і валюту.
namespace ProductApp.Models;
// Гроші: "USD 1,500.00" або "UAH 45000"
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Currency} {Amount:N2}";
// Парсинг рядка "USD 1500" або "1500 UAH"
public static bool TryParse(string? input, out Money? money)
{
money = null;
if (string.IsNullOrWhiteSpace(input)) return false;
var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
// Формат: "USD 1500" або "1500 USD"
if (decimal.TryParse(parts[1], out var amount1))
{
money = new Money(amount1, parts[0].ToUpper());
return true;
}
if (decimal.TryParse(parts[0], out var amount2))
{
money = new Money(amount2, parts[1].ToUpper());
return true;
}
return false;
}
}
using Microsoft.AspNetCore.Mvc.ModelBinding;
using ProductApp.Models;
namespace ProductApp.ModelBinders;
public class MoneyModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
// Отримуємо значення з форми/query за ім'ям параметра
var value = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName);
if (value == ValueProviderResult.None)
{
// Значення не знайдено — Model Binding пропускає
return Task.CompletedTask;
}
var stringValue = value.FirstValue;
if (Money.TryParse(stringValue, out var money))
{
// Успіх: встановлюємо результат
bindingContext.Result = ModelBindingResult.Success(money);
}
else
{
// Помилка: додаємо до ModelState
bindingContext.ModelState.TryAddModelError(
bindingContext.ModelName,
$"Неправильний формат суми. Очікується: 'USD 1500' або '1500 UAH'. Отримано: '{stringValue}'");
}
return Task.CompletedTask;
}
}
using Microsoft.AspNetCore.Mvc.ModelBinding;
using ProductApp.Models;
namespace ProductApp.ModelBinders;
// Provider — "фабрика" для конкретного типу
public class MoneyModelBinderProvider : IModelBinderProvider
{
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
// Повертаємо наш binder тільки для типу Money
if (context.Metadata.ModelType == typeof(Money))
return new MoneyModelBinder();
return null; // null = використовуй інший provider
}
}
builder.Services.AddControllersWithViews(options =>
{
// Вставляємо на початок — перевіряється першим
options.ModelBinderProviders.Insert(0, new MoneyModelBinderProvider());
});
Або простіший варіант — через [ModelBinder] атрибут:
// Позначаємо тип напряму (якщо не хочемо реєструвати Provider)
[ModelBinder(typeof(MoneyModelBinder))]
public record Money(decimal Amount, string Currency) { ... }
Будуємо покроково Controller що демонструє всі інструменти Model Binding.
namespace ProductApp.Models;
public record Product(
int Id,
string Title,
string Category,
Money Price,
int StockCount,
bool IsAvailable
);
namespace ProductApp.Models;
public record SearchFilters(
string? Query,
string? Category,
decimal? MinPrice,
decimal? MaxPrice,
bool? InStockOnly,
string SortBy = "title",
int Page = 1,
int PageSize = 10
);
public record CreateProductDto(
string Title,
string Category,
Money Price, // ← наш custom type!
int StockCount
);
ViewBag, а сповіщення про успіх — у TempData. Якщо вам цікаво, як саме вони працюють під капотом — ці механізми (разом з ViewData та ViewModel) будуть об'єктом детального розбору у наступній статті 06.using Microsoft.AspNetCore.Mvc;
using ProductApp.Models;
using ProductApp.Services;
namespace ProductApp.Controllers;
public class ProductSearchController : Controller
{
private readonly IProductService _service;
public ProductSearchController(IProductService service)
{
_service = service;
}
// ── SEARCH: фільтрація через [FromQuery] ───────────────────────
// GET /productsearch?query=laptop&category=electronics&minPrice=1000
public async Task<IActionResult> Index(
[FromQuery] string? query,
[FromQuery] string? category,
[FromQuery] decimal? minPrice,
[FromQuery] decimal? maxPrice,
[FromQuery] bool? inStockOnly,
[FromQuery] string sortBy = "title",
[FromQuery] int page = 1)
{
var filters = new SearchFilters(
query, category, minPrice, maxPrice, inStockOnly, sortBy, page);
var products = await _service.SearchAsync(filters);
// Передаємо фільтри назад у View для збереження стану форми
ViewBag.Filters = filters;
return View(products);
}
// ── DETAILS: параметр з маршруту ──────────────────────────────
// GET /productsearch/details/42
// GET /productsearch/details?id=42
public async Task<IActionResult> Details(
[FromRoute] int id) // лише з маршруту, не з query
{
var product = await _service.GetByIdAsync(id);
if (product is null) return NotFound();
return View(product);
}
// ── SORT: демонстрація змішаних джерел ───────────────────────
// GET /productsearch/sort/{sortField}?page=2&category=electronics
[HttpGet("productsearch/sort/{sortField}")]
public async Task<IActionResult> SortedList(
[FromRoute] string sortField, // з маршруту: "title", "price", "date"
[FromQuery] int page = 1, // з query string
[FromQuery] string? category = null) // з query string
{
var filters = new SearchFilters(
null, category, null, null, null, sortField, page);
var products = await _service.SearchAsync(filters);
ViewBag.SortField = sortField;
return View("Index", products);
}
// ── CREATE: форма і збереження з [FromForm] ───────────────────
// GET /productsearch/create
[HttpGet]
public IActionResult Create() =>
View(new CreateProductDto("", "", new Money(0, "UAH"), 0));
// POST /productsearch/create
// Price приймається як "UAH 45000" і конвертується через MoneyModelBinder
[HttpPost]
public async Task<IActionResult> Create([FromForm] CreateProductDto dto)
{
if (!ModelState.IsValid)
return View(dto);
var product = await _service.CreateAsync(dto);
TempData["Success"] = $"Товар «{product.Title}» (ціна: {product.Price}) додано!";
return RedirectToAction(nameof(Index));
}
// ── API-LIKE: прийом JSON через [FromBody] ────────────────────
// POST /productsearch/batch-create
// Content-Type: application/json
// Body: [{ "title": "...", "category": "..." }, ...]
[HttpPost("productsearch/batch-create")]
public async Task<IActionResult> BatchCreate([FromBody] List<CreateProductDto> dtos)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var created = new List<Product>();
foreach (var dto in dtos)
created.Add(await _service.CreateAsync(dto));
return Json(new { count = created.Count, products = created });
}
// ── API KEY CHECK: [FromHeader] ───────────────────────────────
// GET /productsearch/premium-data
// Header: X-Access-Level: premium
[HttpGet("productsearch/premium-data")]
public IActionResult PremiumData(
[FromHeader(Name = "X-Access-Level")] string? accessLevel)
{
if (accessLevel != "premium")
return Unauthorized();
return Json(new { data = "Секретні преміум дані" });
}
}
@model List<ProductApp.Models.Product>
@{
var filters = ViewBag.Filters as ProductApp.Models.SearchFilters;
ViewData["Title"] = "Пошук товарів";
}
<h1>Пошук товарів</h1>
@* Форма фільтрів — GET запит зберігає параметри у URL *@
<form method="get" class="mb-4">
<div class="row g-2">
<div class="col-md-4">
<input type="text" name="query" class="form-control"
placeholder="Назва товару..."
value="@filters?.Query">
</div>
<div class="col-md-2">
<input type="text" name="category" class="form-control"
placeholder="Категорія"
value="@filters?.Category">
</div>
<div class="col-md-2">
<input type="number" name="minPrice" class="form-control"
placeholder="Ціна від"
value="@filters?.MinPrice">
</div>
<div class="col-md-2">
<input type="number" name="maxPrice" class="form-control"
placeholder="Ціна до"
value="@filters?.MaxPrice">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Шукати</button>
</div>
</div>
<div class="mt-2">
<div class="form-check form-check-inline">
<input type="checkbox" name="inStockOnly" value="true"
class="form-check-input"
@(filters?.InStockOnly == true ? "checked" : "")>
<label class="form-check-label">Тільки в наявності</label>
</div>
</div>
</form>
@if (!Model.Any())
{
<p class="text-muted">Товарів за вашим запитом не знайдено.</p>
}
else
{
<p>Знайдено: <strong>@Model.Count</strong> товар(ів)</p>
<div class="row row-cols-1 row-cols-md-3 g-3">
@foreach (var product in Model)
{
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<a asp-action="Details" asp-route-id="@product.Id">
@product.Title
</a>
</h5>
<p class="card-text text-muted">@product.Category</p>
<p class="card-text">
<strong>@product.Price</strong>
</p>
@if (!product.IsAvailable)
{
<span class="badge bg-secondary">Немає в наявності</span>
}
</div>
</div>
</div>
}
</div>
}
@model ProductApp.Models.CreateProductDto
@{ ViewData["Title"] = "Новий товар"; }
<h1>Додати товар</h1>
<div class="row">
<div class="col-md-6">
<form asp-action="Create" method="post">
<div class="mb-3">
<label asp-for="Title" class="form-label">Назва</label>
<input asp-for="Title" class="form-control">
<span asp-validation-for="Title" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Category" class="form-label">Категорія</label>
<input asp-for="Category" class="form-control">
</div>
<div class="mb-3">
<label asp-for="Price" class="form-label">
Ціна (формат: "UAH 45000" або "USD 1500")
</label>
@* asp-for="Price" виведе Money.ToString() як value *@
<input name="Price" class="form-control"
placeholder="UAH 1000" value="@Model.Price">
<span asp-validation-for="Price" class="text-danger"></span>
<div class="form-text">
Вказуйте валюту і суму через пробіл: UAH 45000
</div>
</div>
<div class="mb-3">
<label asp-for="StockCount" class="form-label">Кількість</label>
<input asp-for="StockCount" class="form-control" type="number" min="0">
</div>
<button type="submit" class="btn btn-primary">Додати товар</button>
<a asp-action="Index" class="btn btn-outline-secondary">Скасувати</a>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
dotnet run
Тестуємо різні URL:
GET /productsearch → всі товари
GET /productsearch?query=laptop → пошук по назві
GET /productsearch?category=electronics&minPrice=5000 → фільтр
GET /productsearch/details/1 → [FromRoute] id=1
GET /productsearch/sort/price?page=2 → [FromRoute] + [FromQuery]
POST /productsearch/create з form body → [FromForm] + MoneyModelBinder
Завдання 1.1. Додайте до ProductSearchController Action Compare що приймає список id через query string: /productsearch/compare?ids=1&ids=3&ids=5. Параметр — List<int> ids. View показує порівняльну таблицю знайдених товарів.
Завдання 1.2. Реалізуйте [FromHeader] — Action що читає Accept-Language заголовок і повертає повідомлення відповідною мовою (uk → "Привіт!", en → "Hello!", інше → "Hi!").
Завдання 2.1. Розширте MoneyModelBinder: він має також підтримувати формати "$1500" (USD), "€500" (EUR), "₴45000" (UAH). Напишіть юніт-тест для Money.TryParse з різними входами.
Завдання 2.2. Реалізуйте DateRangeModelBinder для типу DateRange(DateOnly From, DateOnly To). Прийом з форми: два поля {paramName}.from і {paramName}.to. Якщо from > to — помилка у ModelState. Використайте у ProductSearchController для фільтрації за датою додавання.
Завдання 3.1. Реалізуйте захист від over-posting для форми редагування продукту через TryUpdateModelAsync:
Title, Category, Price, StockCount, CreatedAt, CreatedByTitle, Category, PriceCreatedAt і CreatedBy — незмінні навіть якщо зловмисник додасть їх у POST-запитCreatedAt=2000-01-01 і переконайтесь що значення не змінилося[BindProperty] — головна відмінність від Razor Pages[FromRoute], [FromQuery], [FromForm], [FromBody] — явні атрибути для контролю джерелаOrder.Address.Street)items[0], items[1] у формі → List<T> у параметріIModelBinder + IModelBinderProvider для власних типівTryUpdateModelAsync: whitelist полів для захисту від over-postingУ наступній статті — Views, ViewData, ViewBag, TempData і ViewModel: як передавати дані з Controller у View і яким чином зберігати стан між запитами.
Маршрутизація в MVC: Convention vs Attribute Routing
MVC-специфічна маршрутизація: convention routing з MapDefaultControllerRoute, attribute routing з [Route] та [HttpGet], route tokens, реєстрація Areas. Демо-проєкт: BlogController з кастомними SEO-friendly URLs.
Views, ViewData, ViewBag, TempData і ViewModel
Передача даних з Controller у View в ASP.NET Core MVC: три підходи (ViewData, ViewBag, strongly-typed ViewModel), структура папки Views, TempData для cross-request повідомлень, Partial Views у MVC. Демо: MovieController з Flash Messages та комплексним ViewModel.