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 от внутренней модели. Дальше — как валидировать эти данные.

Проверьте себя
1. Зачем использовать DTO вместо передачи сущности БД напрямую?
ADTO быстрее компилируется
BDTO отделяет контракт API от модели БД и защищает от overposting
CDTO не нужны в современных версиях
DDTO заменяют базу данных
2. Откуда по умолчанию (с [ApiController]) берётся сложный объект-параметр?
AИз строки запроса
BИз тела запроса (JSON)
CИз заголовков
DИз cookie