user@elrise.ru:~
2026-07-237 min readphpsymfonydoctrineddddtoarchitecture

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 LayerDoctrine, 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. Ожидаемое поведение простое: новый телефон записывается, два остальных поля остаются без изменений.

Наивные решения быстро создают неоднозначность:

Проблема не в конкретном синтаксисе проверки. Проблема в потерянной семантике. DTO говорит «значение не передано», а маппинг видит только значение типа int или string и вынужден угадывать, что имел в виду вызывающий код. В результате Entity получает корректный с точки зрения типов, но неправильный с точки зрения use-case-а вызов.

Решение: отдельная семантика для «не передано»

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

DTO задаёт sentinel в качестве default для поля. Application Layer передаёт значения дальше единым способом, не добавляя отдельную ветку для каждого свойства. Компонент на границе сравнивает вход с sentinel и принимает решение:

Ключевой момент — решение принимается на Application–Domain границе. Entity получает только нормальные доменные значения и не знает, что снаружи поле не передали. Поэтому семантика частичного обновления не расползается по Entity, setter-ам и доменным сервисам.

Это не единственный возможный способ представить отсутствие значения. Его преимущество в данном случае в том, что он работает с типизированными DTO и позволяет отделить отсутствие изменения от валидного null, 0, пустой строки или другого default-значения.

Одна граница — две политики строгости

Одинаковая логика маппинга нужна для разных источников входа, но одинаковая политика ошибок им не подходит.

Это важнее, чем выбор конкретного класса. Один и тот же 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 и у каждого входа одинаковая семантика полей, простой явный маппинг может быть понятнее.

Специализированный слой оправдан, когда:

Если этих условий нет, 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 действительно важны.


Полезные ссылки:

Обсуждение

Комментарии (0)

Пока никто не комментировал.