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