Model binding и DTO
Model binding и DTO: как JSON из запроса превращается в C#-объект.
Суть: model binding автоматически собирает параметры метода из частей запроса: тела (JSON), маршрута, query-строки, заголовков. DTO (Data Transfer Object) — специальный класс под входные/выходные данные, отделённый от моделей БД.
Когда приходит POST с JSON, кто-то должен превратить этот текст в типизированный C#-объект. Это делает model binding. А чтобы не светить наружу внутреннюю структуру БД, используют DTO — лёгкие классы, описывающие именно контракт API.
Источники данных
[HttpPost("{teamId:int}")]
public IActionResult Create(
int teamId, // из маршрута
[FromQuery] bool notify, // из ?notify=true
[FromBody] CreateUserDto dto,// из тела (JSON)
[FromHeader(Name="X-Trace")] string? trace) // из заголовка
{
// ...
}
public class CreateUserDto
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
По умолчанию (с [ApiController]) сложные типы берутся из тела, простые — из маршрута/query. Атрибуты [FromBody], [FromQuery], [FromRoute], [FromHeader] задают источник явно.
Как работает под капотом
Для тела ASP.NET Core берёт поток запроса, читает JSON и через System.Text.Json десериализует его в DTO: по именам свойств сопоставляет поля JSON с полями класса. Для маршрута/query берёт строковые значения и конвертирует в нужные типы. Если конвертация не удалась (например "abc" в int), поле помечается ошибкой в ModelState. Промоделируем сопоставление JSON и DTO на Python.
# Маппинг входного "JSON" (dict) на "DTO" с проверкой типов
raw = {"name": "Аня", "email": "[email protected]", "age": "25"}
dto_schema = {"name": str, "email": str, "age": int}
def bind(raw, schema):
result, errors = {}, []
for field, typ in schema.items():
value = raw.get(field)
if value is None:
errors.append(f"{field}: обязательное поле")
continue
try:
result[field] = typ(value) # конвертация в нужный тип
except (ValueError, TypeError):
errors.append(f"{field}: ожидался {typ.__name__}")
return result, errors
dto, errors = bind(raw, dto_schema)
print("DTO:", dto)
print("Ошибки:", errors)
Попробуй сам ▶ — age приходит строкой "25" и конвертируется в int, ровно как model binding превращает query-параметры в числа. Если подставить нечисловой age, увидите ошибку — это и есть наполнение ModelState.
Частые ошибки
- Принимать модель БД напрямую. Клиент может прислать поля, которые не должен трогать (Id, IsAdmin) — overposting. DTO защищает.
- Забыть про
[FromBody]при сложных сценариях. Без[ApiController]привязка из тела не всегда автоматическая. - Несовпадение имён. JSON
userNameне свяжется с C#Nameбез настройки именования.
Best practices
- Всегда используйте отдельные DTO для входа и выхода — не возвращайте сущности БД напрямую.
- Делайте разные DTO для создания и обновления (
CreateUserDto,UpdateUserDto) — у них разные поля. - Для маппинга DTO в сущность используйте ручной маппинг или библиотеки (Mapster, AutoMapper).
Жизненный цикл привязки и источники по приоритету
Model binding перебирает источники в определённом порядке и собирает из них значения параметров. Для простых типов это маршрут, строка запроса и форма; для сложных по умолчанию (с [ApiController]) — тело запроса. Явные атрибуты [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm] снимают любую двусмысленность. Важно: тело можно привязать только к одному параметру — поток запроса читается один раз, поэтому два [FromBody] не работают.
После привязки идёт конвертация типов: строка "42" из URL становится int, "true" — bool, дата разбирается из ISO-строки. Если конвертация невозможна, в ModelState добавляется ошибка, и с [ApiController] запрос завершается ответом 400 ещё до входа в метод. Это значит, что внутри метода вы почти всегда работаете с уже корректно типизированными данными.
DTO как контракт и защита от overposting
Главная причина существования DTO — отделить контракт API от внутренней модели данных. Сущность User в БД может содержать PasswordHash, IsAdmin, CreatedAt — но в ответе клиенту этим полям не место, а в запросе на создание клиент не должен иметь возможности выставить IsAdmin. Если принимать сущность напрямую, злоумышленник пришлёт лишнее поле и повысит себе права — это и есть атака overposting (mass assignment). DTO с ровно нужными полями закрывает эту дыру.
На практике заводят несколько DTO под разные операции: CreateUserDto (без Id), UpdateUserDto (свой набор изменяемых полей), UserResponseDto (что отдаём наружу). Маппинг между DTO и сущностью делают вручную или через библиотеки вроде Mapster/AutoMapper. Да, это немного «лишнего» кода, но он окупается безопасностью и тем, что внутреннюю модель можно менять, не ломая публичный контракт.
Итог: model binding собирает объект из частей запроса, а DTO отделяет контракт API от внутренней модели. Дальше — как валидировать эти данные.