user@elrise.ru:~
· [активен]· 1.1.0тегиphpsymfonydoctrineddddtoarchitecture

dto-entity-updater — DTO → Entity с sentinel-паттерном для DDD

DTO → Entity mapping с sentinel-паттерном для разделения «не передано» и «передано как default».

→ repository

Обзор

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_INTintполя типа int, integer
Placeholder::NONE_STRINGstringполя типа string
Placeholder::NONE_FLOATfloat / doubleполя типа float, double
Placeholder::NONE_ARRAYarrayполя типа array, list, iterable
Placeholder::NONE_DEFAULTbool, mixedtype-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 payloadDtoEntityUpdater (lenient)Неизвестные поля допускаются и логируются.
Command handler внутри bounded contextRepositoryHelperTrait::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 репозитория.

Источники

Обсуждение

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

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