Pull to refresh

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

Reading time9 min
Views12K

После прочтения статьи Введение в проектирование сущностей, проблемы создания объектов на хабре, я решил написать развернутый комментарий о примерах использования Domain-driven design (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 Domain\Client\Request;

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

namespace Domain\Client\Request;

class DelegateClient
{
    public $new_manager; // Manager
}

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


namespace Domain\Client;

class Client
{

    private $id;
    private $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 $date_create; // \DateTime
    private $date_update; // \DateTime

    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 $date_action; // \DateTime

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

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

    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 Domain\Client\Request;

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

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

namespace Domain\Client\Request;

class RenameClient
{
    public $new_name = '';
    public $date_action; // \DateTime
    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 Domain\Client;

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

    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 Symfony\Component\HttpFoundation\Request;

class RegisterClient
{
    public $name = '';
    public $manager; // Manager
    public $address; // Address
    public $date_action; // \DateTime
    public $user; // User
    public $user_ip = '';
    public $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 $id;
    private $name = '';
    private $manager; // Manager
    private $address; // Address
    private $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 Domain\Security\UserAction;

interface AuthorizedUserActionInterface
{
    public function getUser() : User;

    public function getUserIp() : string;

    public function getUserAgent() : string;

    public function getDateAction() : \DateTime;
}

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


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


namespace Domain\Security\UserAction;

use Symfony\Component\HttpFoundation\Request;

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 $name = '';
    protected $manager; // Manager
    protected $address; // Address
    protected $date_action; // \DateTime
    protected $user; // User
    protected $user_ip = '';
    protected $user_agent = '';

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

    //... 
}

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


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

    // значительно проще стал выглядеть конструктор
    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;
        $this->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($this, 'delegate', $request));
    }

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

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

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


Ссылки


Tags:
Hubs:
+13
Comments8

Articles