Мапперы и DTO: когда они нужны, а когда мешают
DTO защищает границу приложения, а маппер переводит данные между слоями. Но добавлять их к каждой сущности без причины — значит оплачивать лишний код тестами, ревью и ошибками синхронизации.

Маппер и DTO не ускоряют запросы и не делают архитектуру «правильной» сами по себе. Они нужны там, где данные внешнего API, базы и доменной модели начинают расходиться. На небольшом CRUD-проекте отдельный слой преобразований часто только увеличивает объём кода, а в публичном API или системе с несколькими источниками данных отсутствие границы быстро приводит к утечке внутренних полей и дорогим изменениям.
Что именно разделяют DTO и маппер
DTO (Data Transfer Object) — объект передачи данных*. Он описывает форму сообщения на границе: HTTP-запроса, ответа, сообщения в очереди или вызова другого сервиса. DTO не обязан совпадать с таблицей в базе и тем более не обязан повторять структуру доменной сущности.
Маппер переводит один формат в другой. Например, он превращает строку из базы в доменную модель или доменную модель в публичный ответ API. Это обычная функция или класс с понятным направлением преобразования:
type UserRow = {
id: number;
email: string;
password_hash: string;
created_at: Date;
};
type User = {
id: number;
email: string;
createdAt: Date;
};
type UserResponse = {
id: number;
email: string;
createdAt: string;
};
function userFromRow(row: UserRow): User {
return {
id: row.id,
email: row.email,
createdAt: row.created_at,
};
}
function userToResponse(user: User): UserResponse {
return {
id: user.id,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
}
Пример рассчитан на TypeScript 5.x и не зависит от конкретного веб-фреймворка. Поле password_hash существует только в записи хранилища. В ответ API оно не попадает не из-за случайного исключения, а потому что маппер явно задаёт разрешённый контракт.
Сноска. DTO не равен модели базы данных: первый описывает передачу данных между границами, вторая отражает хранение. Иногда они совпадают по полям, но это совпадение, а не требование архитектуры.
Если изменение схемы таблицы должно менять публичный JSON-ответ без отдельного решения команды, граница между хранилищем и API уже слишком тонкая.
Три рабочих подхода
1. Отдавать модель напрямую
Сервис получает запись из ORM или репозитория и сериализует её в ответ. Для прототипа, внутренней панели и простого CRUD это самый дешёвый путь: меньше типов, функций и файлов.
Цена проявляется позже. Переименование поля в базе может изменить API. Новая связь ORM способна добавить в сериализацию лишние данные. Метод или служебное поле модели случайно становятся частью контракта. Если проект живёт недолго и границы действительно совпадают, риск может быть приемлемым. Для публичного API — обычно нет.
2. DTO на границе, но без отдельного маппера
Сервис формирует объект ответа прямо в обработчике или в application-сервисе. Это уже защищает контракт, но логика преобразования остаётся рядом с бизнес-сценарием.
function makeUserResponse(user: User): UserResponse {
return {
id: user.id,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
}
Подход разумен, когда преобразование короткое и используется один раз. Его слабое место — повторение. Если тот же пользователь появляется в списке, аудите и ответе администратора, похожие объекты начинают расходиться: в одном месте дата строка, в другом — число, в третьем случайно появляется внутренний идентификатор.
3. DTO и именованные мапперы
Преобразования выносятся в отдельные функции с направлением в имени: userFromRow, userToResponse, userFromCreateRequest. Доменный код не знает о snake_case базы, HTTP-формате и названиях колонок.
Цена — дополнительные типы и тесты. Для каждой границы нужно решить, какие поля обязательны, как обрабатывать null, даты, перечисления и отсутствующие значения. Зато изменение API не требует переписывать доменную модель, а аудит данных становится локальной задачей.
| Подход | Когда подходит | Что выигрываете | Чем платите |
|---|---|---|---|
| Модель напрямую | Прототип, внутренний CRUD, один слой | Минимум кода и быстрый старт | Связь API со схемой хранения, риск утечки полей |
| DTO без отдельного маппера | Один короткий ответ или команда | Явный контракт без нового слоя | Повторение преобразований при росте API |
| DTO плюс мапперы | Публичный API, несколько источников, долгий проект | Контроль границ и независимые изменения | Больше типов, кода и тестов |
Как принять решение без архитектурной религии
Смотрите не на размер класса, а на количество границ и направлений изменений. Для каждого сценария ответьте на четыре вопроса:
- Есть ли внешний потребитель? Публичный клиент, мобильное приложение или партнёрский API требуют стабильного DTO. Внутренний экран, который меняется вместе с сервером, может обойтись без него.
- Совпадают ли жизненные циклы? Если схема базы меняется чаще API или наоборот, прямую сериализацию лучше не использовать.
- Сколько источников данных? База, кеш, внешняя CRM и очередь почти всегда имеют разные формы. Маппер скрывает эту разницу от бизнес-логики.
- Есть ли риск раскрытия? Пароли, токены, внутренние флаги, стоимость закупки и служебные идентификаторы нельзя отдавать «по умолчанию». Явный DTO делает список разрешённых полей видимым в коде.
Не создавайте четыре класса ради одной страницы, если у вас один источник, один потребитель и нет отдельной доменной модели. Начните с локального DTO или обычной функции преобразования. Выносите маппер, когда появляется второй потребитель, повторяющееся преобразование или независимый контракт.
Что ломается чаще всего
Маппер копируют вручную
Самая опасная ошибка — считать поля и забыть, что контракт изменился. Добавление свойства в доменную модель не должно автоматически добавлять его в ответ, но должно заставить команду принять решение. Для критичных DTO полезны тесты на форму результата и отдельные тесты на запрещённые поля.
DTO используют как бизнес-модель
Входной DTO часто содержит строки из HTTP-запроса: например, дату или идентификатор. Доменной модели нужны уже проверенные значения и правила. Если передавать DTO глубоко в домен, проверка формата, транспорта и бизнеса смешивается в одном типе.
Один DTO пытаются использовать во всех направлениях
Запрос создания пользователя, ответ списка и ответ профиля имеют разные требования. Универсальный тип быстро обрастает необязательными полями. В итоге клиент не понимает, что действительно приходит, а сервер проверяет комбинации полей в десятках мест.
Маппер выполняет скрытую работу
Преобразование должно быть предсказуемым. Если функция сама делает запросы, вызывает внешнюю систему или молча подставляет значения, её трудно тестировать и повторно использовать. Запросы выполняйте в сервисе или репозитории, а мапперу передавайте уже полученные данные.
Где подход ломается
DTO и мапперы не решают проблемы версионирования API, миграций базы, валидации или производительности автоматически. Большой вложенный ответ может быть дорогим независимо от того, собран он маппером или сериализатором ORM. Также не стоит превращать каждый простой объект в отдельный слой: чрезмерная абстракция повышает стоимость чтения кода.
Особенно осторожно работайте с частичными обновлениями. Для PATCH значение «поле отсутствует» может означать «не менять», а null — «очистить». Если DTO теряет это различие при преобразовании, маппер меняет данные не так, как просил клиент.
Что делать дальше
Возьмите один реальный endpoint и нарисуйте цепочку данных: входной запрос, application-сервис, доменная модель, запись базы и ответ. Для каждого перехода отметьте, меняются ли имена, типы и доступность полей. Если переходов нет и контракт совпадает с таблицей, не добавляйте слой ради шаблона. Если граница есть, оформите её типом DTO, напишите чистый маппер и тест на чувствительные поля.
После этого смотрите в проде не на количество классов, а на число несовместимых изменений API, ошибки сериализации и время ревью схемы ответа. Эти показатели лучше показывают пользу мапперов, чем сама длина файла.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: TypeScript Handbook: Everyday Types, Martin Fowler: Data Transfer Object
Частые вопросы
DTO и маппер нужны в каждом проекте?
Можно ли использовать DTO как модель базы данных?
Где проверять входной DTO?
Что делать, если мапперы начали дублироваться?
Подойдёт ли этот подход для другой версии TypeScript?
Что делать, если в PATCH пропадают null и отсутствующие поля?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.