Відповідь на введення в проектування сутностей, проблеми створення об'єктів

Після прочитання статті Введення в проектування сутностей, проблеми створення об'єктів на хабре, я вирішив написати розгорнутий коментар про приклади використання 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 Domain\Client\Request;

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

namespace Domain\Client\Request;

class DelegateClient
{
public Manager $new_manager;
}

На основі запиту від користувача ми створюємо DTO, валидируем і створюємо/редагуємо сутність на його основі.
namespace Domain\Client;

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

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

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

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

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 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 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, що у кожного він свій.
Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.