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-режима