DTO → Entity в DDD: почему «не передано» ≠ «передано как default»
В DDD DTO приходит из Application Layer, а Entity живёт в Domain Layer. Между ними нужен маппинг: обновить только те поля, которые реально пришли, и не протащить транспортную семантику внутрь домена. На практике именно здесь «не передано» часто превращается в «сбросить до default».
elriseio/dto-entity-updater предлагает один из способов закрыть этот шов. Статья не является справочником по API бандла: она разбирает архитектурную проблему и решение. Полный код, контракты, sentinel-таблица и тесты находятся на странице проекта и в репозитории.
Откуда это
В задачах с DDD-моделью я раз за разом видел один и тот же участок: command handler получает DTO и должен переложить его в Domain Entity. Пока DTO описывает полный объект, маппинг выглядит механическим. Как только появляется PATCH или частичное обновление, значение поля начинает нести два разных смысла: конкретное значение и отсутствие изменения.
Это не сравнение всех способов маппинга DTO в Entity. Здесь предложен один вариант архитектурного решения: сохранить семантику входа на Application–Domain границе, не заставляя Entity знать о DTO и не размазывая проверки по handler-ам.
Граница, которую нельзя размывать
В слоёной DDD-архитектуре у трёх уровней разные обязанности:
| Слой | Ответственность |
|---|---|
| Application Layer | оркестрация use-case-а, command handler, DTO и подготовка входа для домена |
| Domain Layer | агрегаты, Entity, value objects, доменные сервисы и бизнес-инварианты |
| Infrastructure Layer | Doctrine, persistence, сообщения и интеграции с внешним миром |
DTO — объект Application Layer. Он знает форму внешнего входа и может отражать особенности HTTP, сообщения или другого транспорта. Domain Entity не должна знать ни о DTO, ни о PATCH, ни о том, какое поле пришло из JSON.
Это не эстетическое пожелание. Если Entity начинает принимать DTO или содержит условие «если DTO-поле null, не трогай setter», граница между бизнес-правилами и транспортом уже нарушена. Entity начинает обслуживать формат входа, а не доменную модель. Тестировать её отдельно становится сложнее, потому что в доменную логику проникает знание об Application Layer.
Отсюда следует конкретная задача: Application Layer должен передать домену не DTO, а нормальный набор намерений по изменению Entity. При этом он обязан сохранить информацию о том, какие поля были пропущены.
Где ломается частичное обновление
Представим PATCH профиля пользователя. В запросе есть name, email и phone, но пользователь меняет только phone. Ожидаемое поведение простое: новый телефон записывается, два остальных поля остаются без изменений.
Наивные решения быстро создают неоднозначность:
- Проверять каждое поле через
isset. Работает, но превращает каждый update-вызов в набор повторяющегося boilerplate и плохо сочетается с типизированными readonly DTO. - Использовать
nullкак признак отсутствия. Это невозможно для обычныхint,string,floatиarray, еслиnullне входит в их тип. Кроме того,nullиногда является настоящим значением, которое нужно записать. - Использовать обычное default-значение как sentinel.
0дляintили пустая строка дляstringмогут быть валидными значениями. Как только default совпадает с намеренным вводом, код перестаёт отличать «не менять» от «записать именно это».
Проблема не в конкретном синтаксисе проверки. Проблема в потерянной семантике. DTO говорит «значение не передано», а маппинг видит только значение типа int или string и вынужден угадывать, что имел в виду вызывающий код. В результате Entity получает корректный с точки зрения типов, но неправильный с точки зрения use-case-а вызов.
Решение: отдельная семантика для «не передано»
Нужен специальный маркер, который означает не конкретное значение, а отсутствие изменения. Такой маркер обычно называют sentinel: это заранее определённое значение, отличимое от допустимых значений предметного поля и имеющее отдельный смысл на границе маппинга.
DTO задаёт sentinel в качестве default для поля. Application Layer передаёт значения дальше единым способом, не добавляя отдельную ветку для каждого свойства. Компонент на границе сравнивает вход с sentinel и принимает решение:
- sentinel означает «оставить поле как есть»;
- обычное значение означает «вызвать соответствующий setter Entity».
Ключевой момент — решение принимается на Application–Domain границе. Entity получает только нормальные доменные значения и не знает, что снаружи поле не передали. Поэтому семантика частичного обновления не расползается по Entity, setter-ам и доменным сервисам.
Это не единственный возможный способ представить отсутствие значения. Его преимущество в данном случае в том, что он работает с типизированными DTO и позволяет отделить отсутствие изменения от валидного null, 0, пустой строки или другого default-значения.
Одна граница — две политики строгости
Одинаковая логика маппинга нужна для разных источников входа, но одинаковая политика ошибок им не подходит.
- Для DTO, пришедшего из HTTP, JSON или внешнего message bus, неизвестное поле может быть нормальным следствием расширения внешней схемы. Такой путь разумно обрабатывать lenient: пропустить неизвестное поле и оставить диагностический лог.
- Для typed-вызова из command handler-а или сервисного слоя неизвестный accessor обычно означает ошибку программы. Такой путь должен быть strict: завершиться сразу и показать проблему рядом с местом вызова.
Это важнее, чем выбор конкретного класса. Один и тот же sentinel-паттерн получает две политики в зависимости от происхождения входа: внешний транспорт не должен ломать обработчик из-за лишнего поля, а ошибка внутри собственного typed-кода не должна тихо исчезать.
dto-entity-updater реализует именно эту модель двумя исполнителями. Бандл не меняет Entity и не управляет её lifecycle; он формализует поведение Application–Domain шва. Детали выбора исполнителя, сигнатуры и обработка ошибок описаны на странице проекта.
Как решение ложится в архитектуру
Поток ответственности выглядит так:
HTTP / CLI / Messenger
│
▼
── Application Layer ────────────────────────
Command Handler
│
▼
DTO (input из транспорта)
│
▼
Entity updater ◀── граница маппинга
│
▼
Domain Entity (aggregate root, value objects)
── Domain Layer ─────────────────────────────
│
▼
── Infrastructure Layer ────────────────────
Repository / Doctrine ORM / DB
Диаграмма не описывает внутренний pipeline бандла. Она показывает архитектурную ответственность: DTO заканчивается на Application–Domain границе, а Entity остаётся частью домена и принимает только доменные значения.
Когда такой слой оправдан
Отдельный updater нужен не каждому CRUD-проекту. Если форма полностью заменяет Entity и у каждого входа одинаковая семантика полей, простой явный маппинг может быть понятнее.
Специализированный слой оправдан, когда:
- проект регулярно принимает частичные обновления;
- DTO типизированы и не могут использовать
nullкак универсальный маркер; - один и тот же маппинг повторяется в нескольких command handler-ах;
- важно, чтобы Entity не знала о формате внешнего входа;
- для внешнего и внутреннего источника нужны разные политики неизвестных полей.
Если этих условий нет, sentinel и отдельный updater могут стать церемонией. Проблема должна быть реальной: слой нужен не ради ещё одной абстракции, а ради сохранения смысла частичного обновления.
Где смотреть реализацию
На странице проекта находятся установка, API, полный набор sentinel-констант, два исполнителя, примеры, тесты и лицензия. В dummy-market-agent можно поднять reference Symfony-сервис через Docker и посмотреть, как Application Layer, CQRS, API Platform и persistence собираются в приложении с реальными границами слоёв.
Итог
DTO и Domain Entity принадлежат разным слоям. Если при маппинге потерять разницу между «не передано» и «передано как default», система начнёт менять данные, которых use-case менять не просил. Исправление — сохранить эту семантику на Application–Domain границе, не протаскивая DTO в домен.
dto-entity-updater — один из вариантов реализации такого шва: sentinel отделяет отсутствие изменения от обычного значения, а lenient/strict политики разводят внешний и typed-вход. Это не универсальный маппер для любой архитектуры, а специализированное решение для проектов, где частичное обновление и чистая Domain Entity действительно важны.
Полезные ссылки:
- DTO Entity Updater: реализация, API и тесты
- dummy-market-agent — reference Symfony-сервис с Application Layer, CQRS, API Platform и persistence
- Domain-Driven Design, Эрик Эванс — идея многослойной архитектуры и разделения Application / Domain / Infrastructure
- Implementing Domain-Driven Design, Вон Вернон — разделение CQRS и инвариант «Domain Entity не знает про DTO»
- Symfony Serializer — официальный инструмент маппинга Symfony
- PSR-3 Logger Interface — контракт логирования для lenient-режима