Избыточность DTO в backend-API и чёткие границы
Рост DTO в backend-API ведёт к дублирующимся мапперам и расплывчатым правилам. Узнайте, как разделять контроллеры, задания и обработчики, чтобы уменьшить шум и упростить изменения.

Почему один и тот же объект постоянно копируется
Обычно всё начинается с одной безобидной копии. Контроллер читает тело запроса, превращает его в DTO, передаёт другой DTO в сервис, а затем для фоновой задачи появляется своя версия, потому что асинхронный код не должен зависеть от веб-моделей. Пока ничего особо не видно.
Потом меняется одно поле. Может быть, customerNote становится note, или phone разбивается на countryCode и number. Одно небольшое изменение затрагивает класс запроса, вход сервиса, payload задания, тело события, тестовые фикстуры и методы маппинга. Данные почти не изменились, но правка разносится по половине кодовой базы.
Имена тоже расходятся, даже когда форма остаётся той же. OrderUpdateRequest, UpdateOrderInput, OrderPatchCommand и OrderJobData могут нести одинаковые поля в чуть другом порядке. Через несколько месяцев никто не понимает, описывают ли эти имена реальный бизнес-смысл или старые случайности, которые никто не убрал.
Этот дрейф создаёт проблему крупнее, чем просто лишнее печатание. Копии стирают правила. Если только менеджеры могут менять скидку, где должна жить эта проверка? Команды часто разбрасывают её по контроллерам, мапперам, обработчикам и заданиям, потому что каждый слой владеет своей версией данных. Код по-прежнему работает, но правило не имеет чёткой «дома».
В краткой перспективе так кажется быстро. Вы копируете класс, переименовываете два поля и двигаетесь дальше. Pull request остаётся маленьким, и вы избегаете трудной дискуссии о границах. Позже приходит счёт за уборку.
Каждый новый поток требует ещё одного маппера. Тесты растут вокруг копий вместо поведения. Рефакторы тормозят, потому что люди боятся сломать одну из тихих дубликатных сущностей, о которой забыли.
Такое разрастание редко появляется из-за одного плохого решения. Оно растёт из множества маленьких «временных» решений. Каждая копия экономит десять минут сегодня. После достаточного количества таких копий простое переименование поля может съесть полдня, и никто не сможет сказать, какой объект — источник правды.
Где обычно копии накапливаются
Большинство команд не создаёт десять версий одного объекта специально. Копии появляются слой за слоем.
Контроллер читает JSON в UpdateOrderRequest. Задание в очереди упаковывает те же поля в UpdateOrderJob. Внутренний обработчик запрашивает UpdateOrderCommand. Затем API возвращает OrderResponse, который часто выглядит похоже на первоначальный запрос.
На HTTP-границе это кажется нормальным. Тела запросов нужно парсить, валидировать и давать понятные ошибки. Отдельная форма для внешнего ввода здесь имеет смысл. Проблема начинается, когда эта модель края продолжает просачиваться внутрь. Вместо единственного маппинга люди продолжают клонировать структуру и переименовывать её.
Очереди усугубляют проблему. Payload заданий обычно требует стабильной сериализованной формы — это реальная граница. Но команды часто копируют каждое поле из запроса, даже когда воркеру нужен только orderId, actorId и небольшой патч. В итоге то же обновление существует в двух или трёх почти идентичных типах. Через неделю кто-то добавляет couponCode в одном месте и забывает про остальные.
Далее появляются входы обработчиков. Методы сервисов и command-обработчики часто принимают свои DTO, потому что каждый слой хочет выглядеть аккуратно на бумаге. В коде это превращается в эстафету мапперов, которые делают не больше, чем копируют имена.
Ответы могут снова удвоить количество типов. Многие API возвращают модель, которая зеркально повторяет форму запроса, даже если клиенту нужны только статус, метки времени и действительно изменившиеся поля. Такая симметрия кажется аккуратной, но раздутой.
Простое правило помогает: разделяйте модели только там, где граница реальна. Внешний ввод — одна граница. Транспорт очередей — может быть другой. Внутреннему коду обычно нужно меньше форм, чем кажется.
Если вы просмотрите поток и увидите те же пять полей, копирующихся через контроллер, задание, обработчик и ответ — это проблема. Потери — не только лишние классы. Это дрейф между ними, пропущенное поле, устаревшее правило валидации и час, потраченный на поиск настоящего источника правды.
Нарисуйте границы, прежде чем именовать типы
Команды часто копируют объекты, потому что никогда не называют границу, которую пересекают. Один объект входит в контроллер, затем такая же форма протекает в сервисы, задания и обработчики событий. Через несколько месяцев никто не знает, какие поля важны, а какие просто пришли «проездом».
Более чистый подход — дать каждой границе небольшой контракт. Сеть отправляет одну форму. Бизнес-логика использует другую. Фоновая работа несёт третью, часто меньшую, полезную нагрузку. Это не значит, что нужны слои бюрократии. Это значит, что у каждого слоя только то, что ему действительно нужно.
Проблемы обычно начинаются, когда модель запроса становится объектом по умолчанию везде. Это удобно неделю и раздражает годами. Клиенты посылают опциональные поля, вложенные блобы и имена, удобные на проволоке, но не внутри кода.
Короткий набор правил достаточен:
- DTO запросов описывают, что клиент может прислать: имена полей, форматы и возможность null.
- Входы сервиса описывают, что нужно бизнес-логике для принятия решения.
- Payload заданий содержит только те факты, которые воркер не может безопасно пересчитать позже.
Второе правило особенно важно. Бизнес-логике не должно быть важно, назвал ли API поле user_id, userId или завернул ли его в более крупный объект. Замапьте это один раз на краю. Затем передавайте небольшой вход, соответствующий языку домена.
Для фоновой работы требуются ещё большая дисциплина. Задания часто живут дольше запросов, поэтому они должны нести стабильные данные. Предпочитайте идентификаторы, метки времени и несколько фиксированных значений вместо полного снимка запроса. Если воркер может загрузить свежие данные — дайте ему эту возможность. Если нет — включите только те поля, которые действительно должны сохраниться.
Держите одно короткое правило для каждой границы, и делайте его настолько коротким, чтобы коллега мог его запомнить. Для многих команд этого достаточно: контроллер валидирует и маппит, сервис решает, задания несут минимум.
Хороший тест прост: если email-воркеру нужен только order_id и template, не отправляйте в очередь весь объект обновления заказа.
Рефакторьте один болезненный поток в первую очередь
Эта проблема редко исчезает большим переписыванием. Она уменьшается быстрее, если вы исправите один поток, который уже раздражает: endpoint с избыточным кодом маппинга, множеством почти одинаковых типов и множеством передач.
Выберите путь, который раздражает команду каждую неделю. Хорошая кандидатура — там, где есть контроллер DTO, сервис DTO, job DTO и, возможно, ещё копия для обработчика событий — и все они несут почти одинаковые поля.
Первый проход держите простым:
- Выберите один endpoint и проследите полный путь от запроса до записи в базу, очереди и последующих обработчиков. Запишите каждую форму объекта по пути.
- Оставьте транспортный DTO на краю. Пусть он занимается HTTP-именами, query-строками, nullable-полями и валидацией ввода. Не передавайте этот же объект глубже просто потому, что он уже существует.
- Преобразуйте запрос в одну внутрненнюю форму. Это может быть команда вроде
UpdateProfileили доменный объект, если сценарий подходит. - Обрежьте payload заданий до ID и нескольких фактов, которые воркеры действительно нуждаются сейчас.
- Оставьте старые копии на местах до тех пор, пока тесты не пройдут. Затем удалите неиспользуемые DTO и мапперы одним коммитом уборки.
Это работает, потому что каждому слою даётся одна задача. Контроллер читает транспортные данные. Прикладной слой обрабатывает намерение. Задания несут только то, что нужно фоновому коду.
Поток обновления профиля хорошо иллюстрирует идею. Запрос может содержать строки вроде display_name и timezone. Контроллер валидирует их, затем создаёт UpdateUserProfile. Очередное задание по обновлению аватара нуждается только в userId и avatarVersion, а не в полном теле запроса.
Сделайте это один раз, и следующий рефактор будет проще. Команды обычно видят, что один очищенный endpoint убирает удивительно много кода маппинга и делает последующие изменения гораздо менее болезненными.
Простой пример обновления заказа
Админ открывает экран заказов и меняет статус заказа с "paid" на "shipped". Именно здесь часто начинается беспорядок: один объект запроса попадает в контроллер, затем та же форма копируется в объект сервиса, payload очереди и модель ответа, хотя каждому слою нужно разное.
Держите контроллер близко к HTTP. Он должен читать сырые поля вроде orderId, status и, может быть, опциональной заметки, и валидировать их как данные запроса. Если status отсутствует или неверен, контроллер должен остановиться и вернуть ошибку. Эта проверка принадлежит краю, потому что она про ввод пользователя, а не про бизнес-правила.
После этого контроллер должен собрать маленькую команду для обработчика. Что-то вроде ChangeOrderStatus(orderId, newStatus, changedBy) достаточно. Обработчику не нужны заголовки, query-параметры или полный body запроса. Ему нужны только данные, необходимые чтобы решить, можно ли перевести заказ в новый статус.
Разделение простое:
- Контроллер читает и валидирует сырые поля запроса.
- Обработчик получает небольшую команду с бизнес-данными.
- Задание получает только
orderIdи имя события вродеorder_shipped. - Модель ответа строится после завершения работы.
Внутри обработчика код загружает заказ, проверяет правила, обновляет статус и сохраняет. Если заказ уже отменён, обработчик отклоняет изменение. Если обновление прошло, он может поставить в очередь фоновую задачу для письма или аудита.
Этому заданию не нужен весь объект заказа. Отправляйте только ID заказа и имя события. Когда задание выполнится через пять минут, оно может загрузить свежие данные вместо того, чтобы полагаться на старую копию.
Ответ для админского экрана тоже должен храниться отдельно. Постройте его в конце из обновлённого заказа и подготовьте под UI. Может быть, экрану нужны id, status, updatedAt и сообщение вроде "Order marked as shipped". Это модель ответа, а не доменная модель и не payload задания.
Это кажется небольшим изменением, но оно убирает много шума. Каждый слой получает компактный объект с понятной задачей, и заказ перестаёт менять форму при каждом переходе по коду.
Когда повторное использование помогает, а когда вредит
Повторное использование нормально, когда два слоя понимают одно и то же, одинаково. Если сервис и воркер работают с простым объектом «счёт к отправке», один тип может быть достаточен. Не нужно каждую границу оборачивать заново.
Проблемы начинаются, когда команды повторно используют тип только потому, что он «вроде подходит». Объект запроса контроллера часто содержит шум транспорта: параметры query, сырые строки, nullable-поля, детали пагинации, контекст авторизации или флаги, добавленные для одного endpoint. Такая форма может подходить для HTTP, но редко подходит для домена.
Простое правило: используйте один тип, когда смысл остаётся тем же; разделяйте, когда смысл меняется. "User profile update request" и "profile update command" звучат похоже, но не всегда это одна и та же вещь. Запрос может допускать частичный ввод и свободные форматы. Команда должна быть строже и более надёжной.
Обычно тип можно переиспользовать, если оба слоя нуждаются в тех же полях, применимы те же правила валидации, имена полей означают одно и то же, и ни один слой не несёт протокольные детали.
Если один слой добавляет вопросы транспорта — разделяйте типы рано. Это включает заголовки, коды статусов, сырые JSON-имена, счётчики повторов, метаданные сообщений или поля, которые существуют только потому, что один endpoint их шлёт. Эти детали распространяются быстро. Скоро у вас появится общий struct с 25 полями, половина из которых опциональна, и никто не знает, какие пять действительно важны.
Такой «экономящий» реюз экономит несколько минут сейчас и тратит часы позже. Люди добавляют очередное nullable-поле вместо того, чтобы спросить, всё ли ещё имеет смысл модель.
Небольшие конверсии обычно лучше. Короткая функция маппинга скучна — и это хорошо. Она делает границу видимой. В одном месте можно очистить ввод, выставить дефолты и отвергнуть значения, которые никогда не должны попасть в домен.
Если конверсия кажется повторяющейся, сначала проверьте модель. Исправление, возможно, в лучших границах, а не в ещё большем общем типе.
Ошибки, которые поддерживают разрастание
Одна распространённая ошибка — копирование форм по имени, а не по смыслу. OrderDto в контроллере может означать сырый ввод пользователя, в то время как OrderDto в воркере — доверенные данные, которые уже прошли проверки. Одинаковые имена — разная роль.
Ещё ошибка — добавление полей «про запас». Небольшой объект запроса постепенно набирает флаги статуса, внутренние заметки, рассчитанные суммы и метки для ответа. Никто не хочет убирать поле позже, поэтому каждый слой принимает больше, чем нужно. Объект выглядит удобным, но перестаёт нести ясный смысл.
Валидация тоже разносит проблему. Контроллер проверил, что поле существует. Сервис проверяет снова чуть иначе. Фоновая задача повторяет логику, потому что сообщения в очереди могут прийти из другого пути. Через несколько месяцев одно правило живёт в трёх местах, и никто не знает, какое верное.
Лучшее разделение проще: валидация ввода на краю, а глубже — бизнес-правила в обработчике.
Очереди создают ещё одну кучу копий. Команды часто отправляют полные объекты, потому что так кажется быстрее, чем снова загружать данные. Этот обходной путь стареет плохо. Как только схема сообщения меняется, старые задания ломаются или воркерам приходится писать неудобные fallback-методы. В большинстве случаев отправляйте ID и позволяйте воркеру загрузить свежие данные. Отправляйте снимок полного состояния только тогда, когда важен именно тот момент времени.
Модели ответов тоже вредят, когда их начинают путать с доменными моделями. Ответ API существует для клиентов: он часто плоский, переименованный или содержит поля для отображения. Доменные модели существуют для бизнес-логики. Если смешать их, небольшое изменение API прокатится по обработчикам, заданиям и критическим правилам.
Прежде чем добавить ещё один DTO, задайте простейший вопрос: несёт ли этот объект новый смысл, или это просто ещё одна копия с чуть другим набором полей? Если смысл не поменялся — новый тип, вероятно, не нужен.
Быстрая проверка перед добавлением ещё одного DTO
Большая часть этой путаницы начинается с разумной мысли: "этот слой должен иметь свой объект." Иногда это правда. Часто это просто copy-paste с новым именем.
Быстрый обзор помогает. Возьмите один поток, например обновление заказа. Запрос приходит в контроллер, задание идёт в очередь, а обработчик пишет в базу. Прежде чем добавить новый тип, проверьте, действительно ли этому слою нужна иная форма или просто меньше полей.
Задайте несколько прямых вопросов:
- Что этот слой обязан знать, чего предыдущий слой не должен нести?
- Если ответ "ничего", зачем создавать новый тип?
- Если слою нужно скрыть несколько полей, можно ли убрать их на краю вместо клонирования всего объекта?
- Кто валидирует каждое поле, и сможет ли новый сотрудник быстро найти это правило?
- Если это payload в очереди, будут ли старые сообщения работать после изменения схемы?
Разные объекты имеют смысл, когда границы реальны. Внешний контракт API, публичное событие и доменная модель часто требуют разных правил. Внутренние переходы в рамках одного случая использования обычно не требуют отдельной формы.
Небольшое повторение норм для жизни. Подлинная цена — не в строках кода, а в запутанном владении. Когда имена, поля и правила валидации согласованы, код легче изменять, и новые DTO перестают появляться по привычке.
Что делать дальше в реальном коде
Эта проблема обычно уменьшается, когда команда проводит аудит одного фича-пути и исправляет его до конца. Выберите поток, которым часто пользуются — например, "update order" или "create invoice" — и проследите его от HTTP-запроса до контроллера, сервиса, задания, обработчика и записи в базу.
Запишите все типы, которые появляются на этом пути. Затем посчитайте все маппинги. Само число часто говорит многое. Запрос, который становится контроллерным DTO, затем DTO сервиса, затем payload очереди и затем входом обработчика, обычно означает, что в коде научились копировать сначала и объяснять потом.
Простой аудит уместится на одной странице:
- перечислите каждый объект в потоке в порядке появления
- отметьте, где поля меняют имена или форму
- пометьте, зачем существует каждый маппинг
- удалите маппинги, которые лишь переименовывают поля без реальной причины границы
Некоторые маппинги нормальны. Валидация на краю — это хорошо. Payload очереди может требовать меньшей формы, чем запрос. Доменная модель может требовать правил, которые транспортная форма не должна нести. Оставьте такие места. Оспаривайте остальное. Если никто не может объяснить, зачем нужен маппинг — вероятно, его не должно быть.
Правила именования помогают больше, чем команды обычно ожидают. Используйте один паттерн для типов транспорта, один для команд и один для доменных моделей. Имена вроде UpdateOrderRequest, UpdateOrderCommand и Order просты и уменьшают дрейф. Люди перестают гадать, отличаются ли OrderDto, OrderData и OrderPayload по смыслу.
При code review это либо приживается, либо разваливается. Перед появлением нового DTO задавайте прямой вопрос: защищает ли этот тип границу, или он просто переносит те же данные снова?
Если команда слишком близка к проблеме, чтобы оценить её трезво, внешний ревьюер поможет. Oleg Sotnikov на oleg.is работает с стартапами и малыми компаниями как fractional CTO, и такой тип очистки границ — именно та практическая архитектурная задача, с которой он обычно помогает, не требуя полного переписывания.
Часто задаваемые вопросы
Как понять, мешает ли моему API разрастание DTO?
Вы, скорее всего, столкнулись с этим, если одни и те же поля повторяются в классах запросов, входах сервисов, payload для очередей и моделях ответов с незначительными переименованиями. Другой явный признак — простое переименование поля, которое требует правок в мапперах, фикстурах, обработчиках и коде очередей одновременно.
Когда отдельный DTO имеет смысл?
Создавайте новый DTO, когда вы действительно пересекаете границу и смысл данных меняется. HTTP-входы, транспорт для очередей и ответы клиенту часто требуют своих форм. Внутри одного сценария лучше оставить один внутренний объект, если поля и правила остаются одинаковыми.
Стоит ли передавать request DTO напрямую в сервис?
Обычно — нет. DTO запросов содержат детали транспорта: имена полей, необязательные значения и правила парсинга. Замапьте их один раз в контроллере, затем передавайте более компактный объект — команду или вход, соответствующий языку домена.
Что положить в payload задания очереди?
Держите payload маленьким и стабильным. Отправляйте идентификаторы, метки времени и несколько значений, которые воркер обязан сохранить в момент постановки в очередь. Если воркер может загрузить свежие данные при выполнении — пусть так и делает, вместо того чтобы полагаться на снимок полного запроса.
Где должна жить валидация?
Проверку входных данных помещайте на границе — там вы парсите и отбрасываете некорректные запросы. Бизнес-правила храните в обработчике или сервисе, где известны текущие состояния и можно принять решение (например, разрешено ли переводить заказ в новый статус).
Нужны ли отдельные модели ответов?
Да, когда клиенту нужен формат, отличный от доменной модели. Модель ответа может уплощать структуру, переименовывать поля или добавлять текст, удобный для UI. Стройте модель ответа в конце потока, не переиспользуйте доменные объекты или payload для очередей как ответ.
Как это почистить без большого переписывания?
Начните с одного цикла, который раздражает команду чаще всего. Проследите путь от HTTP-запроса до контроллера, задания и обработчика. Оставьте DTO транспорта на краю, создайте одну внутрненую команду, уменьшите payload для очереди и удалите старые копии после прохождения тестов.
Нужен ли маппер для каждого слоя?
Нет. Маппить стоит только на тех границах, где действительно меняются правила или форма данных. Если два слоя означают одно и то же — ещё один маппер добавляет шум и ещё одно место для дрейфа.
Какое именование помогает сохранить границы ясными?
Используйте простую схему именования, которая показывает принадлежность объекта: для транспорта — UpdateOrderRequest, для команд — UpdateOrderCommand, для домена — Order. Такие явные имена уменьшают догадки и визуально показывают, где объект уместен.
Какое простое правило можно использовать прежде чем добавить ещё один DTO?
Перед добавлением типа задайте один вопрос: защищает ли этот объект границу, или он просто копирует те же данные снова? Если смысл не поменялся, новый DTO, скорее всего, не нужен — лучше сохранить путь проще.