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