После прочтения статьи Введение в проектирование сущностей, проблемы создания объектов на хабре, я решил написать развернутый комментарий о примерах использования 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;
        $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, что у каждого он свой.


Ссылки


Поделиться с друзьями
-->

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


  1. VolCh
    16.02.2017 10:52
    +2

    Подобные DTO я применяю при передаче данных от контроллера (MVC) к модели (MVC) в виде слоя сервисов приложения (DDD), но не в самом домене. Обычно объект запроса десериализируется и валидируется даже без написания хотя бы строчки кода (аннотации — не код :) ) и передается в сервис приложения типа DelegateClientToManagerService->execute();, которые уже с его помощью ищет клиента и менеджера в репозиториях и вызывает метод $client->delegateManagmentTo($manager). Так, по-моему, модель гораздо чище, в неё не протекает ничего из приложения, только термины бизнеса, где обычно нет выражение типа «запрос на передачу клиента новому менеджеру», есть «передача клиента менеджеру». (не буду заострять внимание, что передача клиента от одно менеджера к другому обычно не ответственность клиента, а или одного из менеджеров, либо их начальника вообще, а сам клиент сущность подневольная и менеджера ему именно назначают извне, пускай Client::assignNewManager($manager), но по сути это будет обычный сеттер).


    1. VolCh
      16.02.2017 11:25
      +2

      И, да, не считаю нужным вводить в модель кто и когда её создавал и модифицировал, если это делается только для административных целей, а напрямую в бизнес-процессах не участвует. А если участвует (дата платежа и кассир, например), то для этого отдельные поля типа Payment::date и Payment::cashier, в которых как правило инфа из логов дублируется, но не всегда, то задним числом провести нужно, то за кассира создать — тогда в бизнес-сущностях отражается бизнес-информация, а в логирующих — фактическая.


      1. ghost404
        16.02.2017 12:58

        И, да, не считаю нужным вводить в модель кто и когда её создавал и модифицировал, если это делается только для административных целей

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


        Но, как я уже говорил, логирование на уровне сущностей может быть нужно для голосований, если мы хотим ограничить количество голосов от одного пользователя. Если голосовать могут только авторизированные пользователи, то мы привязываем голос к пользователю по его ID, а если могу и анонимные, то нужно генерировать GUID на основе IP и User-Agent.


        Еще дата создания сущности может быть нужна для применения миграций, когда нам нужно например, изменить что-то в записях которые были созданы после 2017-01-01. Можно конечно извлечь эту информацию из лога, но это очень трудоемко.


        А вот еще один реальный бизнес-процесс: удалять из бд все записи старше 6 месяцев. Без знания даты создания сощности это сделать невозможно.


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


        Ну и на последок, приведу пример использование DTO на примере счетчика просмотров:


        class Video
        {
            public function view(ViewRequest $request)
            {
                if ($request->getVideo() !== $this) {
                    throw new \InvalidArgumentException();
                }
        
                $this->total_views++;
        
                return new View($request);
            }
        }

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


        Эти навороты делаются для сохранения консистентности данных, то есть чтобы нельзя было в любом месте программы инкрементировать просмотр без реального просмотра.


  1. AbMax792
    16.02.2017 15:28

    "...private int $id;
    private string $name..."
    Разве PHP поддерживает явную типизацию?


    1. ghost404
      16.02.2017 15:31

      Спасибо за замечание.
      Поспешил я, RFC не приняли.
      Я пока еще на 5.6 и путаюсь еще в нововведениях 7.x


    1. ghost404
      16.02.2017 16:44

      Еще один аргумент в пользу использования геттеров и сеттеров в DTO