dto-entity-updater — DTO → Entity с sentinel-паттерном для DDD
DTO → Entity mapping с sentinel-паттерном для разделения «не передано» и «передано как default».
Обзор
dto-entity-updater — Symfony-бандл для маппинга DTO → Domain Entity. Значение Placeholder означает, что поле не передано; обычное значение приводит к вызову setter-а. Бандл поставляет два исполнителя: DtoEntityUpdater для lenient-вызовов и RepositoryHelperTrait::updateField для strict-вызовов.
Бандл не управляет lifecycle Entity и не требует от домена знания о DTO. Архитектурная мотивация маппинга описана в заметке DTO → Entity в DDD: почему «не передано» ≠ «передано как default».
Быстрый старт
Минимальный сценарий: один DTO с Placeholder-сентинелами, один handler, один вызов updater-а.
composer require elriseio/dto-entity-updater
// config/bundles.php
return [
Elrise\Bundle\DtoEntityUpdater\DtoEntityUpdaterBundle::class => ['all' => true],
];
use Elrise\Bundle\DtoEntityUpdater\Domain\Placeholder;
final readonly class TariffDto
{
public function __construct(
public string $name = Placeholder::NONE_STRING,
public int $pricePerUnit = Placeholder::NONE_INT,
) {
}
}
use Elrise\Bundle\DtoEntityUpdater\Domain\DtoEntityUpdaterInterface;
final class UpdateTariffHandler
{
public function __construct(private DtoEntityUpdaterInterface $updater) {}
public function __invoke(Tariff $tariff, TariffDto $dto): void
{
$this->updater->updateEntity($tariff, [
'name' => $dto->name,
'pricePerUnit' => $dto->pricePerUnit,
]);
}
}
$dto->name === Placeholder::NONE_STRING → updater пропустит поле. $dto->name === 'Free' → updater вызовет $tariff->setName('Free'). Тип sentinel-а определяется по типу параметра setter-а через рефлексию; caller не указывает его для каждого поля.
Что это и чем не является
Бандл делает:
- сравнивает значение поля с sentinel-ом, определённым по типу параметра setter-а, и вызывает setter только для обычного значения;
- предоставляет lenient
DtoEntityUpdaterс PSR-3 info-логом для пропущенных полей; - предоставляет strict
RepositoryHelperTrait::updateField, который бросаетInvalidArgumentExceptionдля неизвестного accessor-а.
Бандл не делает:
- не управляет lifecycle Entity (
persist,flush,refresh); - не предоставляет ORM или CQRS-инфраструктуру;
- не валидирует payload и не строит автоматический diff Entity.
Архитектура
Entity updater получает Entity и набор пар field => value из command handler-а. Для каждого поля он находит setter, определяет sentinel по типу параметра и вызывает setter только для несентинельного значения. Неизвестные accessor-ы обрабатываются по политике выбранного исполнителя.
Установка и требования
Команда установки и регистрация бандла приведены в разделе «Быстрый старт».
- PHP 8.3+ (CI проверяет PHP 8.3, 8.4 и 8.5).
- Symfony 7.2+.
- Doctrine ORM 3.x — опционально, только для
RepositoryHelperTrait::findOrFail.
Публичных конфигурационных ключей нет.
Использование
Sentinel-константы (Placeholder)
Полный набор sentinel-ов — по одной константе на каждый примитивный тип:
| Константа | Тип | Когда использовать |
|---|---|---|
Placeholder::NONE_INT | int | поля типа int, integer |
Placeholder::NONE_STRING | string | поля типа string |
Placeholder::NONE_FLOAT | float / double | поля типа float, double |
Placeholder::NONE_ARRAY | array | поля типа array, list, iterable |
Placeholder::NONE_DEFAULT | bool, mixed | type-based default resolution |
Caller не указывает per-field, какой sentinel применять — бандл определяет тип сам по сигнатуре setter-а Entity. Достаточно положить Placeholder::NONE_* в default-значение поля DTO, а updater разберётся.
Service: DtoEntityUpdater (lenient)
Для DTO-driven вызовов — HTTP-тело, JSON-decode, message-bus payload. Неизвестные поля (нет setter-а, нет getter-а, нераспознанный тип setter-а) пропускаются; в lenient-режиме пишется PSR-3 info-лог. Пример вызова приведён в разделе «Быстрый старт».
Trait: RepositoryHelperTrait (strict)
Для typed/programmer вызовов — command handler внутри bounded context и сервисный слой. Неизвестный accessor бросает InvalidArgumentException. Ошибки рефлексии (нет setter-а, getter-а или распознанного типа параметра) бросают LogicException.
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Elrise\Bundle\DtoEntityUpdater\Domain\RepositoryInterface;
use Elrise\Bundle\DtoEntityUpdater\Infrastructure\Doctrine\Repository\RepositoryHelperTrait;
class TariffRepository extends ServiceEntityRepository implements RepositoryInterface
{
use RepositoryHelperTrait;
public function updateTariff(Tariff $tariff, TariffDto $dto): void
{
$this->updateField($tariff, 'name', $dto->name);
$this->updateField($tariff, 'pricePerUnit', $dto->pricePerUnit);
}
}
Какой исполнитель выбирать
| Контекст вызова | Рекомендуемый исполнитель | Почему |
|---|---|---|
| HTTP-тело, JSON-decode, message-bus payload | DtoEntityUpdater (lenient) | Неизвестные поля допускаются и логируются. |
| Command handler внутри bounded context | RepositoryHelperTrait::updateField (strict) | Неизвестный accessor — ошибка программы. |
| Сервисный слой между aggregate-ами | RepositoryHelperTrait::updateField (strict) | Typed-вызов; неизвестный accessor — ошибка программы. |
| Внешний webhook с непредсказуемой схемой | DtoEntityUpdater (lenient) | Неизвестные поля допускаются и логируются. |
Тестирование
Бандл поставляет PHPUnit, PHPStan, php-cs-fixer и phpbench, все подключены через composer-скрипты:
composer test # full PHPUnit run (unit + integration)
composer test-unit # unit suite only
composer test-integration # integration suite only (зарезервирован под itests/docker)
composer phpstan # phpstan level 6 over src/, tests/, bench/
composer phpstan-baseline # regenerate phpstan-baseline.neon
composer cs:check # php-cs-fixer dry-run
composer cs:fix # php-cs-fixer apply
composer bench # phpbench aggregate report
Domain-чистота проверяется отдельным скриптом:
bash scripts/check_domain_purity.sh
CI workflow (.github/workflows/ci.yml) запускает эти проверки на каждом push и pull request в develop и master.
Потрогать руками
dummy-market-agent — reference Symfony-сервис с Application Layer, CQRS, API Platform и DBAL-based persistence. Репозиторий запускается через Docker; в нём доступны API-операции, command/query handlers, persistence, container wiring, границы слоёв и тесты.
Лицензия
Proprietary. Полный текст условий использования, модификации и редистрибуции — в LICENSE репозитория.
Источники
- github.com/elriseio/dto-entity-updater — репозиторий.
- packagist.org/packages/elriseio/dto-entity-updater — Packagist.
- DTO → Entity в DDD: почему «не передано» ≠ «передано как default» — архитектурная мотивация маппинга DTO → Entity.
- Эрик Эванс, Domain-Driven Design — источник идеи многослойной архитектуры.
- Вон Вернон, Implementing Domain-Driven Design — CQRS-разделение и инвариант «Domain Entity не знает про DTO».
- Symfony Serializer — официальная документация по маппингу Symfony.
- PSR-3 Logger Interface — info-логирование пропусков в lenient-режиме.