Ответ на введение в проектирование сущностей, проблемы создания объектов

в 6:50, , рубрики: DDD, design patterns, domain-driven design, php, ооп, Проектирование и рефакторинг

После прочтения статьи Введение в проектирование сущностей, проблемы создания объектов на хабре, я решил написать развернутый комментарий о примерах использования Domain-driven design (DDD), но, как водится, комментарий оказался слишком большим и я посчитал правильным написать полноценную статью, тем более что вопросу DDD, на хабре и не только, удаляется мало внимания.

DDD

Рекомендую прочитать статью о которой я буду здесь говорить.
Если вкратце, то автор предлагает использовать билдеры для контроля за консистентностью данных в сущности при использовании DDD подхода. Я же хочу предложить использование Data Transfer Object (DTO) для этих целей.

Общая структура класса сущности обсуждаемая автором:

final class Client
{
    public function __construct(
        $id,
        $corporateForm,
        $name,
        $generalManager,
        $country,
        $city,
        $street,
        $subway = null
    );

    public function getId(): int;
}

и пример использования билдера

$client = $builder->setId($id)
    ->setName($name)
    ->setGeneralManagerId($generalManager)
    ->setCorporateForm($corporateForm)
    ->setAddress($address)
    ->buildClient();

В детали реализации можно не вдаваться, общий смысл я думаю ясен.
Идея использования билдера в этом примере неплоха, но на мой взгляд билдер здесь не нужен. Вынеся сеттеры из сущности в билдер, они от этого не перестали быть сеттерами. Автор создал лишний билдер, когда можно было просто передать параметры в конструктор или фабричный метод. Забыть сеттер проще чем аргумент.

Я думаю вы и без меня знаете чем плохи сеттеры при DDD подходе. Если коротко, то они нарушают инкапсуляцию и не гарантируют консистентность данных в любой момент времени.

Если мы говорим о DDD, то правильней рассмотреть бизнес процессы связанные с сущностью.

Например, рассмотрим регистрацию нового клиента и передачу существующего клиента другому менеджеру. Это можно рассмотреть как запросы на выполнение операций над сущностью и создать для каждого действия DTO. Получим такую картину:

namespace DomainClientRequest;

class RegisterClient
{
    public string $name = '';
    public Manager $manager;
    public Address $address;
}

namespace DomainClientRequest;

class DelegateClient
{
    public Manager $new_manager;
}

На основе запроса от пользователя мы создаем DTO, валидируем и создаем/редактируем сущность на его основе.

namespace DomainClient;

class Client
{

    private int $id;
    private string $name = '';
    private Manager $manager;
    private Address $address;

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address
    ) {
        $this->id = $generator->generate();
        $this->name = $name;
        $this->manager = $manager;
        $this->address = $address;
    }

    // это фабричный метод, его еще называют именованным конструктором
    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        return new self($generator, $request->name, $request->manager, $request->address);
    }

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->new_manager;
    }
}

Подождите. Это ещё не все. Предположим нам нужно знать когда был зарегистрирована и обновлена карточка клиента. Это делается всего парой строк:

class Client
{
    // ...
    private DateTime $date_create;
    private DateTime $date_update;

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address
    ) {
        // ...
        $this->date_create = new DateTime();
        $this->date_update = clone $this->date_create;
    }

    // ...

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->new_manager;
        $this->date_update = new DateTime();
    }
}

Очевидное на первый взгляд решение имеет недостаток который проявится при тестировании. Проблема в том что мы явно инициалезируем объект даты. В действительности это дата выполнения действия над сущностью и логичным решением будет вынести инициализацию в DTO запроса.

class RegisterClient
{
    // ...
    public DateTime $date_action;

    public function __construct()
    {
        $this->date_action = new DateTime();
    }
}

class DelegateClient
{
    // ...
    public DateTime $date_action;

    public function __construct()
    {
        $this->date_action = new DateTime();
    }
}

class Client
{
    // ...

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address,
        DateTime $date_action
    ) {
        $this->id = $generator->generate();
        $this->name = $name;
        $this->manager = $manager;
        $this->address = $address;
        $this->date_create = clone $date_action;
        $this->date_update = clone $date_action;
    }

    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        return new self(
            $generator,
            $request->name,
            $request->manager,
            $request->address,
            $request->date_action
        );
    }

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->new_manager;
        $this->date_update = clone $request->date_action;
    }
}

Если мы знаем когда редактировалась карточка, то неплохо бы и знать кем она редактировалась. Опять же, логично вынести это в DTO. Запрос на редактирование кто-то же выполняет.

class RegisterClient
{
    // ...
    public User $user;

    public function __construct(User $user)
    {
        // ...
        $this->user = $user;
    }
}

class DelegateClient
{
    // ...
    public User $user;

    public function __construct(User $user)
    {
        // ...
        $this->user = $user;
    }
}

class Client
{
    // ...
    private User $user;

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address,
        DateTime $date_action,
        User $user
    ) {
        $this->id = $generator->generate();
        $this->name = $name;
        $this->manager = $manager;
        $this->address = $address;
        $this->date_create = clone $date_action;
        $this->date_update = clone $date_action;
        $this->user = $user;
    }

    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        return new self(
            $generator,
            $request->name,
            $request->manager,
            $request->address,
            $request->date_action,
            $request->user
        );
    }

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->new_manager;
        $this->date_update = clone $request->date_action;
        $this->user = $request->user;
    }
}

Теперь мы хотим добавить ещё действие над сущностью. Добавим изменение названия клиента и его адреса. Это такие же действия над сущностью как и другие, поэтому создаем DTO по аналогии.

namespace DomainClientRequest;

class MoveClient
{
    public Address $new_address;
    public DateTime $date_action;
    public User $user;

    public function __construct(User $user)
    {
        $this->date_action = new DateTime();
        $this->user = $user;
    }
}

namespace DomainClientRequest;

class RenameClient
{
    public string $new_name;
    public DateTime $date_action;
    public User $user;

    public function __construct(User $user)
    {
        $this->date_action = new DateTime();
        $this->user = $user;
    }
}

class Client
{
    // ...

    public function move(MoveClient $request)
    {
        $this->address = $request->new_address;
        $this->date_update = clone $request->date_action;
        $this->user = $request->user;
    }

    public function rename(RenameClient $request)
    {
        $this->name = $request->new_name;
        $this->date_update = clone $request->date_action;
        $this->user = $request->user;
    }
}

Вы замечаете дублирование кода? Потом будет ещё хуже.

Теперь мы хотим логировать в бд изменение карточки клиента, чтобы знать кому из сотрудников надрать уши в случае чего. Это новая сущность. В лог мы будем писать:

  • Кто
  • Когда
  • Что сделал
  • С какого IP
  • С какого устройства

Я привожу это только как пример. В данном случае можно обойтись лог-файлом, но например в случае голосования или лайков нам может быть важен каждый запрос в отдельности.

namespace DomainClient;

class Change
{
    private Client $client;
    private string $change = '';
    private User $user;
    private string $user_ip = '';
    private string $user_agent = '';
    private DateTime $date_action;

    public function __construct(
        Client $client,
        string $change,
        User $user,
        string $user_ip,
        string $user_agent,
        DateTime $date_action
    ) {
        $this->client= $client;
        $this->change = $change;
        $this->user = $user;
        $this->user_ip = $user_ip;
        $this->user_agent = $user_agent;
        $this->date_action = clone $date_action;
    }
}

Таким образом в DTO действия нам нужно добавить информацию из HTTP запроса.

use SymfonyComponentHttpFoundationRequest;

class RegisterClient
{
    public string $name = '';
    public Manager $manager;
    public Address $address;
    public DateTime $date_action;
    public User $user;
    public string $user_ip = '';
    public string $user_agent = '';

    public function __construct(User $user, string $user_ip, string $user_agent)
    {
        $this->date_action = new DateTime();
        $this->user = $user;
        $this->user_ip = $user_ip;
        $this->user_agent = $user_agent;
    }

    // фабричный метод для упрощения
    public static function createFromRequest(User $user, Request $request) : RegisterClient
    {
        return new self($user, $request->getClientIp(), $request->headers->get('user-agent'));
    }
}

Остальные DTO изменяем по аналогии.

Автора изменения и даты изменения нам уже не нужно хранить в сущности, так-как у нас есть лог изменений. Уберем эти поля из сущности и добавим логирование.

class Client
{
    private int $id;
    private string $name = '';
    private Manager $manager;
    private Address $address;
    private array $changes = []; // Change[]

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address,
        DateTime $date_action,
        User $user,
        string $user_ip,
        string $user_agent
    ) {
        $this->id = $generator->generate();
        $this->name = $name;
        $this->manager = $manager;
        $this->address = $address;
        $this->date_create = clone $date_action;
        $this->changes[] = new Change($this, 'create', $user, $user_ip, $user_agent, $date_action);
    }

    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        return new self(
            $generator,
            $request->name,
            $request->manager,
            $request->address,
            $request->date_action,
            $request->user,
            $request->user_ip,
            $request->user_agent
        );
    }

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->new_manager;
        $this->changes[] = new Change(
            $this,
            'delegate',
            $request->user,
            $request->user_ip,
            $request->user_agent,
            $request->date_action
        );
    }

    // остальные методы по аналогии
}

Теперь мы создаем новый инстанс лога на каждое действие и мы не можем вынести это в отдельный метод так-как различается класс запроса, хотя поля схожы.

Для решения этой проблемы я использую контракты. Давайте создадим такой:

namespace DomainSecurityUserAction;

interface AuthorizedUserActionInterface
{
    public function getUser() : User;

    public function getUserIp() : string;

    public function getUserAgent() : string;

    public function getDateAction() : DateTime;
}

Интерфейс может содержать только методы. Он не может содержать свойства. Это одна из причин по которой я предпочитаю использовать геттеры и сеттеры в DTO, а не публичные свойства.

Сделаем сразу реализацию для быстрого подключения этого контракта:

namespace DomainSecurityUserAction;

use SymfonyComponentHttpFoundationRequest;

trait AuthorizedUserActionTrait
{
    public function getUser() : User
    {
        return $this->user;
    }

    public function getUserIp() : string
    {
        return $this->user_ip;
    }

    public function getUserAgent() : string
    {
        return $this->user_agent;
    }

    public function getDateAction() : DateTime
    {
        return clone $this->date_action;
    }

    // наполнитель для упрощения
    protected function fillFromRequest(User $user, Request $request)
    {
        $this->user = $user;
        $this->user_agent = $request->headers->get('user-agent');
        $this->user_ip = $request->getClientIp();
        $this->date_action = new DateTime();
    }
}

Добавим наш контракт в DTO:

class RegisterClient implements AuthorizedUserActionInterface
{
    use AuthorizedUserActionTrait;

    protected string $name = '';
    protected Manager $manager;
    protected Address $address;
    protected DateTime $date_action;
    protected User $user;
    protected string $user_ip = '';
    protected string $user_agent = '';

    public function __construct(User $user, Request $request)
    {
        $this->fillFromRequest($user, $request);
    }

    //... 
}

Обновим лог изменения клиента чтоб он использовал наш новый контракт:

class Change
{
    private Client $client;
    private string $change = '';
    private User $user;
    private string $user_ip = '';
    private string $user_agent = '';
    private DateTime $date_action;

    // значительно проще стал выглядеть конструктор
    public function __construct(
        Client $client,
        string $change,
        AuthorizedUserActionInterface $action
    ) {
        $this->client = $client;
        $this->change = $change;
        $this->user = $action->getUser();
        $this->user_ip = $action->getUserIp();
        $this->user_agent = $action->getUserAgent();
        $this->date_action = $action->getDateAction();
    }
}

Теперь будем создавать лог изменения на основе нашего контракта:

class Client
{
    // ...

    private function __construct(
        IdGenerator $generator,
        string $name,
        Manager $manager,
        Address $address,
        DateTime $date_action
    ) {
        $this->id = $generator->generate();
        $this->name = $name;
        $this->manager = $manager;
        $this->address = $address;
        $self->date_create = $date_action;
    }

    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        $self = new self(
            $generator,
            $request->getName(),
            $request->getManager(),
            $request->getAddress(),
            $request->getDateAction()
        );
        $self->changes[] = new Change($self, 'register', $request);

        return $self;
    }

    public function delegate(DelegateClient $request)
    {
        $this->manager = $request->getNewManager();
        $this->changes[] = new Change($this, 'delegate', $request);
    }

    public function move(MoveClient $request)
    {
        $this->address = $request->getNewAddress();
        $this->changes[] = new Change($this, 'move', $request);
    }

    public function rename(RenameClient $request)
    {
        $this->name = $request->getNewName();
        $this->changes[] = new Change($this, 'rename', $request);
    }
}

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

class Client implements AggregateEventsInterface
{
    use AggregateEventsRaiseInSelfTrait;

    // ...

    public static function register(IdGenerator $generator, RegisterClient $request) : Client
    {
        // ...
        $self->raise(new ChangeEvent($self, 'register', $request));

        return $self;
    }

    public function delegate(DelegateClient $request)
    {
        // ...
        $this->raise(new ChangeEvent($self, 'delegate', $request));
    }

    // остальные методы по аналогии

    // этот метод будет вызван автоматически при вызове методе $this->raise();
    public function onChange(ChangeEvent $event)
    {
        $this->changes[] = new Change($this, $event->getChange(), $event->getRequest());
    }
}

Это был небольшой пример эволюции проекта с применением DDD подхода. Этот пример не является истиной в последней инсталляции. Многие вещи можно сделать по другому. Тем и хорош DDD, что у каждого он свой.

Ссылки

Автор: ghost404

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js