MyTetra Share
Делитесь знаниями!
О, смотри-ка какое хорошее место. Дайте два!
Проектирование сущностей предметной области
05.05.2017
17:02
Текстовые метки: DDD, PHP, TDD, Yii2, тестирование, программирование, проектирование, сущность
Раздел: Компьютер - Программирование - Язык PHP

Проектирование сущностей предметной области


На почту люди пишут «Куда пропал?» Активно готовлю большой мастер-класс по интернет-магазину и иногда обитаю на форуме. Так там некоторые разработчики порой недоумевают, как можно программировать на фреймворках без использования CRUD и ActiveRecord, и почему такую «лёгкую» на первый взгляд прямую работу с полями в базе данных недолюбливают тру-ООП-шники, предпочитающие DDD.

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

Подходы к разработке

При разработке чего-то на фреймворке у нас есть выбор: либо полностью всё программировать средствами этого каркаса, либо делать более-менее чистое независимое ядро с доменной логикой на чистом PHP и подключать к фреймворку через обвязку в виде неких абстрактных адаптеров.

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

Недопонимание между двумя лагерями программистов возникают из-за наличия диаметрально противоположных подходов к разработке: Database-First и Code-First.

Database-First

Здесь мы начинаем разработку с базы данных:

  1. Проектируем структуру БД;
  2. Создаём таблицы в базе;
  3. Генерируем модели данных;
  4. Генерируем стандартный CRUD;
  5. Вписываем логику куда-попало.

Плюсы:

  • Быстрая генерация кода;
  • Отсутствие лишних преобразований;
  • Идеален для конвейерных проектов.

Минусы:

  • Жёсткая привязка к таблицам;
  • Плоские модели данных;
  • Типы полей совпадают с типами колонок;
  • Костыли с добавлением логики;
  • Невозможность изменения таблиц без изменения кода;
  • Сложность тестирования без БД.

Code-First

А здесь мы сначала пишем код ядра, и только потом привязываем БД:

  1. Продумываем бизнес-логику;
  2. При желании пишем unit-тесты;
  3. Программируем сущности и сервисы;
  4. Привязываем базу данных.

Плюсы:

  • Идеален для индивидуальной разработки;
  • При написании классов не думаем о БД вообще;
  • Чистый ООП без костылей;
  • Использование любых типов полей;
  • Получаем рабочий код без БД;
  • Возможность незаметного изменения структуры БД;

Минусы:

  • Необходимость написания конвертеров из БД в объект и обратно.

В этой статье мы пойдём вторым путём, так как первый известен практически всем из простых примеров в документации.

Постановка задачи

В демонстрационных целях возьём третий пример из второго урока интенсива по ООП с небольшим дополнением. Там разобрано около тридцати примеров за шесть дней, так что возьмём только небольшую часть. Почти как и там спроектируем сущность сотрудника компании примерно по такому заданию:

Сотрудника должен содержать имя, адрес, телефоны, дату создания и признак того, активен он сейчас или его дело спрятано в архив. Телефонов может быть несколько, но обязательно должен быть хотя бы один. И в его деле неоходимо хранить историю помещения его в архив и восстановления. По номеру телефона будут определять страну, слать на него SMS и форматировать его вывод в представлениях, поэтому необходимо хранение его в строгом формате. Нельзя напрямую удалить незаархивированное дело.

В JSON-представлении этот ресурс мог бы выглядеть так:

{

id: 25

name: {

last: 'Пупкин',

first: 'Василий',

middle: 'Петрович',

},

address: {

country: 'Россия',

state: 'Липецкая обл.',

city: 'г. Пушкин',

street: 'ул. Ленина',

house: 25

}

phones: [

{country: 7, code: 920, number: 0000001},

{country: 7, code: 910, number: 0000002},

],

create_date: '2016-04-12 12:34:00',

current_status = 'active',

statuses: [

{status: 'active', date: '2016-04-12 12:34:07'},

{status: 'archive', date: '2016-04-13 12:56:23'},

{status: 'active', date: '2016-04-16 14:02:10'},

];

}

В совокупности это должен быть некий агрегат, содержащий внутри себя наборы вложенных сущностей или объектов-значений.

Каким образом мы будем этот агрегат создавать и как будем им пользоваться? Здесь уже изучим своё техническое задание и попробуем спроектировать методы нашего объекта.

Если хотите построить чистую систему, то забудьте полностью про поля в базе данных и прочий низкоуровневый мусор, который вас обычно отвлекает. Вместо этого начните программировать так, как хотели бы это делать в идеальном мире, где можно делать всё что угодно и как угодно.

При создании нового сотрудника будем требовать указание его имени, адреса и хотя бы одного телефона.

Как мы будем хранить адрес, имя и номер телефона? Числами? Строками? Ассоциативными массивами? Подумаем глобальнее. Давайте вместо чисел и строк придумаем свои собственные типы данных Name, Address и Phone и будем использовать их примерно так:

$employee = new Employee(

new EmployeeId(25),

new Name('Пупкин', 'Василий', 'Петрович'),

new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

[

new Phone(7, '920', '00000001'),

new Phone(7, '910', '00000002'),

]

);

Что у нас здесь есть? Раз нам нужен «сотрудник», то так и переведём на английский и назовём класс Employee. У многих программистов есть привычка всех подряд называть словом User, хотя в мире есть и другие имена сущностей.

Вместо простых строк-значений или чисел-значений мы имя и адрес сделали в виде объектов, которые хранят данные в удобном виде и что-то делают с ними внутри себя. Такой объект называют как «объект-значение» в умных статьях. И это вполне логично. Он не имеет никаких идентификаторов и отдельно никуда не сохраняется. Их можно насоздавать сколько угодно, куда-то передать, вернуть, к чему-то прикрепить. Это либо чей-то параметр, либо результат функции, либо запчасть от чего-то крупного. И для надёжности их можно сделать неизменяемыми, убрав сеттеры и все данные передавая сразу в конструктор, чтобы по пути их никто не испортил. Надо будет сменить имя – просто заменим на новый new Name(...).

Другое дело – наш Employee. У него есть уникальный идентификатор id, по которому мы будем сохранять сотрудника в БД и который там окажется первичным ключом. Мы можем менять его имя, адрес и телефоны. И cотрудник с указанным номером во всей системе может быть только один. Это уже не объект-значение, а полноценная, живая, уникальная и идентифицируемая «сущность». Практически как индивидуальная «личноcть» на фоне «серой массы» в этом мире. В нашем примере мы могли-бы и телефонам добавить некий id, чтобы с ними работать индивидуально. И тогда бы класс Phone тоже оказался сущностью. Именно наличие идентификатора делает любой объект сущностью.

С другой стороны, класс Employee внутри себя содержит вложенные объекты-значения и может содержать наборы других вложенных сущностей. Такой клубок объектов мы можем назвать «агрегатом», корнем которого является сам Employee. При этом Employee помимо конструктора может содержать методы changeAddress, addPhone и подобные для работы с его внутренностями. И в базу данных мы должны сохранять полностью весь такой агрегат. Это тоже вполне логично.

Но что если нам такой же Address понадобится завести не только у Employee, но и у Company? Тогда можем сделать базовый класс Address и сделать его два наследника Employee\Address и Company\Address. Если когда-нибудь они начнут отличаться, то просто уберём наследование.

Для явной типизации идентификаторов мы также придумали некий пользовательский тип EmployeeId. Его задача – хранить идентификатор и следить за тем, чтобы он не был пустым и не менялся. В качестве его значения мы можем использовать либо автоинкрементные числа из секвенций базы данных, либо UUID. Но пока мы только придумываем наружный вид классов и их внутренности нам не важны.

В остальном никаких особенностей пока нет.

Моделирование сущности через написание тестов

Начнём изучать требования и продумывать работу с Employee.

Создание сотрудника мы можем формализовать в простом юнит-тесте:

namespace tests\unit\entities\Employee;

use app\entities\Employee\Address;

use app\entities\Employee\Employee;

use app\entities\Employee\EmployeeId;

use app\entities\Employee\Name;

use app\entities\Employee\Phone;

use Codeception\Test\Unit;

class CreateTest extends Unit

{

public function testSuccess()

{

$employee = new Employee(

$id = new EmployeeId(25),

$name = new Name('Пупкин', 'Василий', 'Петрович'),

$address = new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

$phones = [

new Phone(7, '920', '00000001'),

new Phone(7, '910', '00000002'),

]

);

$this->assertEquals($id, $employee->getId());

$this->assertEquals($name, $employee->getName());

$this->assertEquals($address, $employee->getAddress());

$this->assertEquals($phones, $employee->getPhones());

}

}

Пока мы просто создаём объект и проверяем правильность его заполнения.

Но помимо этого у нас ещё должна быть некая логика, что в объекте при конструировании:

  • выставляется дата создания;
  • сотрудник становится активным;
  • сохраняется история статусов;
  • генерируется событие EmployeeCreated.

Дополним наш тест этими проверками:

class CreateTest extends Unit

{

public function testSuccess()

{

$employee = new Employee(

$id = new EmployeeId(25),

$name = new Name('Пупкин', 'Василий', 'Петрович'),

$address = new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

$phones = [

new Phone(7, '920', '00000001'),

new Phone(7, '910', '00000002'),

]

);

$this->assertEquals($id, $employee->getId());

$this->assertEquals($name, $employee->getName());

$this->assertEquals($address, $employee->getAddress());

$this->assertEquals($phones, $employee->getPhones());

$this->assertNotNull($employee->getCreateDate());

$this->assertTrue($employee->isActive());

$this->assertCount(1, $statuses = $employee->getStatuses());

$this->assertTrue(end($statuses)->isActive());

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeeCreated::class, end($events));

}

}

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

Далее нам необходимо проверить обязательность указания хотя бы одного телефона и уникальность номеров. Для этого можем смоделировать ситуации создания объекта либо без номера, либо с неуникальными номерами. В каждом случае объект должен кидать исключение с соответствующим текстом:

class CreateTest extends Unit

{

...

public function testWithoutPhones()

{

$this->expectExceptionMessage('Employee must contain at least one phone.');

new Employee(

new EmployeeId(25),

new Name('Пупкин', 'Василий', 'Петрович'),

new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

[]

);

}

public function testWithSamePhoneNumbers()

{

$this->expectExceptionMessage('Phone already exists.');

new Employee(

new EmployeeId(25),

new Name('Пупкин', 'Василий', 'Петрович'),

new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

[

new Phone(7, '920', '00000001'),

new Phone(7, '920', '00000001'),

]

);

}

}

Помимо создания сотрудника нужно рассмотреть и другие операции. В отличие от обычного CRUD-а в нашем мире вместо единственной формы редактирования для действия UPDATE мы можем добавить отдельные кнопки для соответствующих команд:

  • смена имени
  • смена адреса
  • архивирование дела
  • восстановление из архива
  • добавление номера
  • удаление номера
  • удаление сотрудника

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

Чтобы больше не копировать new Employee(...) в каждый тест мы можем создать некий помощник-построитель EmployeeBuilder:

namespace tests\unit\entities\Employee;

use app\entities\Employee\Address;

use app\entities\Employee\Employee;

use app\entities\Employee\EmployeeId;

use app\entities\Employee\Name;

use app\entities\Employee\Phone\Phone;

class EmployeeBuilder

{

private $id = 1;

private $phones = [];

private $archived = false;

public function __construct()

{

$this->phones[] = new Phone(7, '000', '00000000');

}

public static function instance()

{

return new self();

}

public function withId($id)

{

$this->id = $id;

return $this;

}

public function withPhones(array $phones)

{

$this->phones = $phones;

return $this;

}

public function archived()

{

$this->archived = true;

return $this;

}

public function build()

{

$employee = new Employee(

new EmployeeId($this->id),

new Name('Пупкин', 'Василий', 'Петрович'),

new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),

$this->phones

);

if ($this->archived) {

$employee->archive(new \DateTimeImmutable());

}

return $employee;

}

}

С его помощью можно создать сотрудника со значениями по умолчанию:

$employee = (new EmployeeBuilder())->build();

Для удобства мы добавили вспомогательный статический конструктор instance(), чтобы не путаться в скобках:

$employee = EmployeeBuilder::instance()->build();

и сделали вспомогательные методы withId, withPhones и archived, чтобы можно было при необходимости подменять значения:

$employee1 = EmployeeBuilder::instance()->withId(7)->build();

$employee2 = EmployeeBuilder::instance()->withId(8)->build();

Этот построитель мы и будем теперь использовать для проверки смены имени:

class RenameTest extends Unit

{

public function testSuccess()

{

$employee = EmployeeBuilder::instance()->build();

$employee->rename($name = new Name('New', 'Test', 'Name'));

$this->assertEquals($name, $employee->getName());

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeeRenamed::class, end($events));

}

}

и адреса:

class ChangeAddressTest extends Unit

{

public function testSuccess()

{

$employee = EmployeeBuilder::instance()->build();

$employee->changeAddress($address = new Address('New', 'Test', 'Address', 'Street', '25a'));

$this->assertEquals($address, $employee->getAddress());

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeeAddressChanged::class, end($events));

}

}

При архивировании дела сотруднка должны поменяться значения геттеров isArchive и isActive, дополниться история статусов и сгенерироваться доменное событие EmployeeArchived. При повторной попытке должна вывалиться ошибка с текстом «Employee is already archived.»:

class ArchiveTest extends Unit

{

public function testSuccess()

{

$employee = EmployeeBuilder::instance()->build();

$this->assertTrue($employee->isActive());

$this->assertFalse($employee->isArchived());

$employee->archive($date = new \DateTimeImmutable('2011-06-15'));

$this->assertFalse($employee->isActive());

$this->assertTrue($employee->isArchived());

$this->assertNotEmpty($statuses = $employee->getStatuses());

$this->assertTrue(end($statuses)->isArchived());

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeeArchived::class, end($events));

}

public function testAlreadyArchived()

{

$employee = EmployeeBuilder::instance()->archived()->build();

$this->expectExceptionMessage('Employee is already archived.');

$employee->archive(new \DateTimeImmutable('2011-06-15'));

}

}

Аналогично будет выглядеть и тест для операции восстановления ReinstateTest, только переключение будет производиться в другую сторону методом $employee->reinstate($date).

Далее осталось придумать и проверить функциональность добавления и удаления номеров телефонов с учётом их уникальности:

class PhoneTest extends Unit

{

public function testAdd()

{

$employee = EmployeeBuilder::instance()->build();

$employee->addPhone($phone = new Phone(7, '888', '00000001'));

$this->assertNotEmpty($phones = $employee->getPhones());

$this->assertEquals($phone, end($phones));

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeePhoneAdded::class, end($events));

}

public function testAddExists()

{

$employee = EmployeeBuilder::instance()

->withPhones([$phone = new Phone(7, '888', '00000001')])

->build();

$this->expectExceptionMessage('Phone already exists.');

$employee->addPhone($phone);

}

public function testRemove()

{

$employee = EmployeeBuilder::instance()

->withPhones([

new Phone(7, '888', '00000001'),

new Phone(7, '888', '00000002'),

])

->build();

$this->assertCount(2, $employee->getPhones());

$employee->removePhone(1);

$this->assertCount(1, $employee->getPhones());

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeePhoneRemoved::class, end($events));

}

public function testRemoveNotExists()

{

$employee = EmployeeBuilder::instance()->build();

$this->expectExceptionMessage('Phone not found.');

$employee->removePhone(42);

}

public function testRemoveLast()

{

$employee = EmployeeBuilder::instance()

->withPhones([

new Phone(7, '888', '00000001'),

])

->build();

$this->expectExceptionMessage('Cannot remove the last phone.');

$employee->removePhone(0);

}

}

И напоследок добавим тест на операцию удаления сотрудника. Этот метод не будет реально удалять запись. Его мы будем вызывать до реального удаления из базы, и он должен только сгенерировать событие EmployeeRemoved или прервать процесс, если кто-то попытается удалить активного сотрудника:

class RemoveTest extends Unit

{

public function testSuccess()

{

$employee = EmployeeBuilder::instance()->archived()->build();

$employee->remove();

$this->assertNotEmpty($events = $employee->releaseEvents());

$this->assertInstanceOf(EmployeeRemoved::class, end($events));

}

public function testNotArchived()

{

$employee = EmployeeBuilder::instance()->build();

$this->expectExceptionMessage('Cannot remove active employee.');

$employee->remove();

}

}

Исходники тестов можно посмотреть в репозитории.

Как видим, код большинства тестовых методов многословный, но достаточно простой. Каждый новый метод добавляется путём копирования предыдущего и небольших правок, поэтому бояться их обилия не нужно.

Практически все такие тесты пишутся прямо в процессе изучения, обсуждения с заказчиком и обдумывания ТЗ (вместо многократного переписывания уже готового кода), поэтому какого-то ощутимого перерасхода времени на написание этот процесс не занимают.

Если вы не хотите, чтобы ваш программист «терял» время на продумывание проекта перед разработкой, то можете тесты от него не требовать. Только тогда не обижайтесь, что он что-то в вашем задании не так понял или что-то не предусмотрел :)

Если сейчас попробуем запустить проверки:

vendor/bin/codecept run unit entities

то увидим ошибки, что этих классов в системе нет:

Unit Tests (16) --------------------------------------------

E ArchiveTest: Success (0.01s)

E ArchiveTest: Already archived (0.00s)

E ChangeAddressTest: Success (0.00s)

CreateTest: Success (0.00s)

CreateTest: Without phones (0.00s)

E CreateTest: With same phone numbers (0.00s)

E PhoneTest: Add (0.00s)

E PhoneTest: Add exists (0.00s)

E PhoneTest: Remove (0.00s)

E PhoneTest: Remove not exists (0.00s)

E PhoneTest: Remove last (0.00s)

E ReinstateTest: Success (0.00s)

E ReinstateTest: Not archived (0.00s)

E RemoveTest: Success (0.00s)

E RemoveTest: Not archived (0.00s)

E RenameTest: Success (0.00s)

------------------------------------------------------------


Time: 175 ms, Memory: 8.00MB

Внешнее проектирование мы закончили.

При желании можно ещё дополнить код другими тестами для каждого класса вроде такого:

class PhoneTest extends Unit

{

public function testIsEqual()

{

$phone1 = new Phone(7, '920', '0000001');

$phone2 = new Phone(7, '920', '0000001');

$this->assertTrue($phone1->isEqualTo($phone2));

}

public function testIsNotEqual()

{

$phone1 = new Phone(7, '920', '0000001');

$phone2 = new Phone(7, '900', '0000002');

$this->assertFalse($phone1->isEqualTo($phone2));

}

}

или подобный тест для Name или Address. Это дополнит покрытие, но...

Вызов методов вроде isEqualTo класса Phone у нас будет производиться только внутри Employee, так как номер телефона - это составная часть объекта сотрудника. Внешнему коду эти вещи не нужны (мы бы могли спокойно объявить метод isEqualTo с модификатором видимости package вместо public, если бы у нас такой был в PHP). Поэтому здесь нет особого смысла в написании отдельного теста для isEqualTo, так как уникальность мы уже полностью протестировали в рамках Employee.

Опишем методы, придуманные нами в тестах:

namespace app\entities\Employee;

class Employee

{

public function __construct(EmployeeId $id, Name $name, Address $address, array $phones) { ... }

public function rename(Name $name) { ... }

public function changeAddress(Address $address) { ... }

public function addPhone(Phone $phone) { ... }

public function removePhone($index) { ... }

public function archive(\DateTimeImmutable $date) { ... }

public function reinstate(\DateTimeImmutable $date) { ... }

public function remove() { ... }

public function isActive() { ... }

public function isArchived() { ... }

public function getId() { return $this->id; }

public function getName() { return $this->name; }

public function getPhones() { return $this->phones; }

public function getAddress() { return $this->address; }

public function getCreateDate() { return $this->createDate; }

public function getStatuses() { return $this->statuses; }

}

Займёмся теперь реализацией внутренностей. Попробуем реализовать класс Employee и его внутренние объекты.

Реализация классов

Сначала реализуем конструктор, принимающий обязательные аргументы и инициализирующий объект текущей датой и активным статусом:

class Employee

{

private $id;

private $name;

private $address;

private $phones = [];

private $createDate;

private $statuses = [];

public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)

{

if (!$phones) {

throw new \DomainException('Employee must contain at least one phone.');

}

$this->id = $id;

$this->name = $name;

$this->address = $address;

$this->phones = [];

$this->createDate = new \DateTimeImmutable();

$this->addStatus(Status::ACTIVE, $this->createDate);

foreach ($phones as $phone) {

foreach ($this->phones as $current) {

if ($current->isEqualTo($phone)) {

throw new \DomainException('Phone already exists.');

}

}

$this->phones[] = $phone;

}

$this->recordEvent(new Events\EmployeeCreated($this->id));

}

...

private function addStatus($value, \DateTimeImmutable $date)

{

$this->statuses[] = new Status($value, $date);

}

}

Что-то слишком много внимания мы уделяем здесь телефонам. Дабы не захламлять класс Employee слежением за уникальностью номеров, лучше добавим некую умную коллекцию Phones, в которую спрячем все необходимые проверки. Поэтому пока вместо простого массива в поле $this->phones присвоим объект new Phones($phones):

class Employee

{

private $id;

private $name;

private $address;

private $phones;

private $createDate;

private $statuses = [];

public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)

{

$this->id = $id;

$this->name = $name;

$this->address = $address;

$this->phones = new Phones($phones);

$this->createDate = new \DateTimeImmutable();

$this->addStatus(Status::ACTIVE, $this->createDate);

$this->recordEvent(new Events\EmployeeCreated($this->id));

}

...

private function addStatus($value, \DateTimeImmutable $date)

{

$this->statuses[] = new Status($value, $date);

}

}

Конструктор стал намного чище. Также мы вынесли отдельный приватный метод addStatus, который нам пригодится не только в конструкторе, но и в операциях архивирования и восстановления.

Далее реализуем методы rename и changeAddress. Никакой сложной логики в них не будет:

namespace app\entities\Employee;

use app\entities\Employee\Events;

class Employee

{

...

public function rename(Name $name)

{

$this->name = $name;

$this->recordEvent(new Events\EmployeeRenamed($this->id, $name));

}

public function changeAddress(Address $address)

{

$this->address = $address;

$this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address));

}

...

}

Они просто меняют значение и куда-то записывают событие. Для чего записывают? И почему бы не воспользоваться стандартной функциональностью событий любого фреймворка?

Работа с сущностями отличается от работы с другими объектами. В любой момент при заполнении или сохранении сущности может произойти неожиданная ошибка (по вине базы данных) или вылететь специально запрограммированное исключение (в ходе проверки внутренних условий). Если генерировать событие сразу, то может возникнуть неприятная ситуация, когда посреди процесса вылетело исключение, транзакция откатилась, а письмо с уведомлением по событию уже отправлено администратору и клиенту.

Для избежания таких неприятностей удобнее использовать систему отложенных событий. А именно, в ходе работы сохранять список событий внутри сущности в приватный массив, а после успешного сохранения извлекать их из сущности и отправлять на обработку:

$entity = new Entity(...); // Создаём сущность

$entity->addItem(...); // и производим все операции.

$repository->save($entity); // Сначала сохраняем в БД,

$events = $entity->releaseEvents(); // потом извлекаем события

$eventDispatcher->dispatch($events); // и отправляем их на обработку.

Это также упрощает unit-тестирование такой сущности. Достаточно проверить содержание массива, вернувшегося из $entity->releaseEvents().

Так и у нас все методы записывают события в приватный массив $events и имеется метод для их извлечения со сбросом:

class Employee

{

private $events = [];

protected function recordEvent($event)

{

$this->events[] = $event;

}

public function releaseEvents()

{

$events = $this->events;

$this->events = [];

return $events;

}

...

}

Такой код нам понадобится во всех агрегатах. Помимо встроенных событий внутри агрегата должен быть некий идентификатор вроде нашего EntityId для первичного ключа. Поэтому удобно классифицировать все корни агрегатов неким обобщённым интерфейсом:

namespace app\entities;

interface AggregateRoot

{

public function getId();

public function releaseEvents();

}

и к этому интерфейсу можно приложить трейт с реализацией работы с $events:

namespace app\entities;

trait EventTrait

{

private $events = [];

protected function recordEvent($event)

{

$this->events[] = $event;

}

public function releaseEvents()

{

$events = $this->events;

$this->events = [];

return $events;

}

}

Теперь любой агрегат можно пометить этим интерфейсом и импортировать в него данный трейт:

namespace app\entities\Employee;

use app\entities\AggregateRoot;

use app\entities\EventTrait;

class Employee implements AggregateRoot

{

use EventTrait;

...

}

Где ещё события могут оказаться полезны кроме рассылки уведомлений? Например, если хотите сделать журнал действий на сайте. Тогда просто начинаете записывать их в логи.

Далее реализуем взаимо-обратные методы archive и reinstate с проверками на корректность текущего статуса (который будем брать из истории):

class Employee implements AggregateRoot

{

use EventTrait;

private $statuses = [];

...

public function archive(\DateTimeImmutable $date)

{

if ($this->isArchived()) {

throw new \DomainException('Employee is already archived.');

}

$this->addStatus(Status::ARCHIVED, $date);

$this->recordEvent(new Events\EmployeeArchived($this->id, $date));

}

public function reinstate(\DateTimeImmutable $date)

{

if (!$this->isArchived()) {

throw new \DomainException('Employee is not archived.');

}

$this->addStatus(Status::ACTIVE, $date);

$this->recordEvent(new Events\EmployeeReinstated($this->id, $date));

}

public function isActive()

{

return $this->getCurrentStatus()->isActive();

}

public function isArchived()

{

return $this->getCurrentStatus()->isArchived();

}

private function getCurrentStatus()

{

return end($this->statuses);

}

...

}

Потом метод для генерации события удаления либо отмены этого процесса:

class Employee implements AggregateRoot

{

...

public function remove()

{

if (!$this->isArchived()) {

throw new \DomainException('Cannot remove active employee.');

}

$this->recordEvent(new Events\EmployeeRemoved($this->id));

}

...

}

И методы управления телефонными номерами с бизнес-логикой проверки на существование и уникальность, которую требуют от нас тесты:

class Employee implements AggregateRoot

{

use EventTrait;

private $phones;

...

public function addPhone(Phone $phone)

{

$this->phones->add($phone);

$this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));

}

public function removePhone($index)

{

$phone = $this->phones->remove($index);

$this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone));

}

...

public function getPhones() { return $this->phones->getAll(); }

}

Как мы уже сказали, всю логику проверки номеров мы инкапсулируем в объект коллекции Phones. Код коллекции можно сделать таким:

namespace app\entities\Employee;

class Phones

{

private $phones = [];

public function __construct(array $phones)

{

if (!$phones) {

throw new \DomainException('Employee must contain at least one phone.');

}

foreach ($phones as $phone) {

$this->add($phone);

}

}

public function add(Phone $phone)

{

foreach ($this->phones as $item) {

if ($item->isEqualTo($phone)) {

throw new \DomainException('Phone already exists.');

}

}

$this->phones[] = $phone;

}

public function remove($index)

{

if (!isset($this->phones[$index])) {

throw new \DomainException('Phone not found.');

}

if (count($this->phones) === 1) {

throw new \DomainException('Cannot remove the last phone.');

}

$phone = $this->phones[$index];

unset($this->phones[$index]);

return $phone;

}

public function getAll()

{

return $this->phones;

}

}

Иначе весь этот код пришлось бы добавить в аналогичные методы класса Employee.

Также мы немного переделали геттер getPhones в классе Employee для получения списка номеров с такого:

public function getPhones()

{

return $this->phones;

}

на такой:

public function getPhones()

{

return $this->phones->getAll();

}

чтобы возвращать только массив номеров, а не объект-коллекцию.

Как видим, в процессе реализации у нас может появляться больше классов, чем мы изначально предполагали (вместо массива можем добавить объект-коллекцию). И общий вспомогательный код может выноситься в отдельные приватные методы. Но нужно ли маниакально тестировать все новые классы и ухищряться с проверкой приватных методов? Если думаете об этом, то просто ответьте на вопрос: волнует ли вашего заказчика, в массиве вы будете хранить объекты внутри Employee или не в массиве?

В реальности нам как пользователю важно только одно: проверить, включается ли микроволновка при нажатии на одну кнопку и выключается ли при нажатии на другую. До её внутренностей нам дела нет. Так и в коде нам важно проверить, правильно ли работает наш Employee снаружи. Поэтому логичнее протестировать только публичные методы Employee, не думая о том, что будет у него внутри: хоть два метода, хоть двести. Это даст нам полную свободу добавления/удаления/переписывания его внутренностей без ненужного переписывания десятков лишних тестов.

В связи с этим для экономии ресурсов достаточно придерживаться разумного принципа тестирования только того, что видно снаружи. Например, можно отдельно протестировать конструктор класса Phone на обязательность полей или его внешний метод getFull().

Далее реализуем остальные классы. Чтобы не производить базовые проверки на empty, in_array и подобные вручную мы можем установить пакет Beberlei/Assert или Webmozart/Assert:

composer require beberlei/assert

Это позволит одной строкой:

Assertion::notEmpty($id);

сэкономить кучу if-ов вроде этого:

if (empty($id)) {

thrown new \InvalidArgumentException('Value "id" is empty, but non empty value was expected.');

}

Теперь для идентификаторов можно сделать базовый класс Id с проверкой на обязательность заполнения:

namespace app\entities;

use Assert\Assertion;

abstract class Id

{

protected $id;

public function __construct($id = null)

{

Assertion::notEmpty($id);

$this->id = $id;

}

public function getId()

{

return $this->id;

}

public function isEqualTo(self $other)

{

return $this->getId() === $other->getId();

}

}

чтобы потом все классы вроде EmployeeId наследовать от него:

namespace app\entities\Employee;

use app\entities\Id;

class EmployeeId extends Id

{

}

Объект для хранения имени будет содержать только базовую валидацию и геттеры, так как имя мы будем рассматривать как неизменяемый объект с полями, доступными только для чтения:

namespace app\entities\Employee;

use Assert\Assertion;

class Name

{

private $last;

private $first;

private $middle;

public function __construct($last, $first, $middle)

{

Assertion::notEmpty($last);

Assertion::notEmpty($first);

$this->last = $last;

$this->first = $first;

$this->middle = $middle;

}

public function getFull()

{

return trim($this->last . ' ' . $this->first . ' ' . $this->middle);

}

public function getFirst() { return $this->first; }

public function getMiddle() { return $this->middle; }

public function getLast() { return $this->last; }

}

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

$employee->getName()->middle = 42;

Аналогично сделаем объект-значение адреса:

namespace app\entities\Employee;

use Assert\Assertion;

class Address

{

private $country;

private $region;

private $city;

private $street;

private $house;

public function __construct($country, $region, $city, $street, $house)

{

Assertion::notEmpty($country);

Assertion::notEmpty($city);

$this->country = $country;

$this->region = $region;

$this->city = $city;

$this->street = $street;

$this->house = $house;

}

public function getCountry() { return $this->country; }

public function getRegion() { return $this->region; }

public function getCity() { return $this->city; }

public function getStreet() { return $this->street; }

public function getHouse() { return $this->house; }

}

Да, логики здесь почти нет.

Приватные поля из аргументов конструктора и геттеры для них в продвинутых IDE генерируются автоматически. Поэтому визг «жутко много кода» слышен только от отчаянных любителей программирования в Notepad++.

Объект статуса будет содержать методы isActive и isArchived для работы аналогичных методов класса Entity:

namespace app\entities\Employee;

use Assert\Assertion;

class Status

{

const ACTIVE = 'active';

const ARCHIVED = 'archived';

private $value;

private $date;

public function __construct($value, \DateTimeImmutable $date)

{

Assertion::inArray($value, [

self::ACTIVE,

self::ARCHIVED

]);

$this->value = $value;

$this->date = $date;

}

public function isActive()

{

return $this->value === self::ACTIVE;

}

public function isArchived()

{

return $this->value === self::ARCHIVED;

}

public function getValue() { return $this->value; }

public function getDate() { return $this->date; }

}

Телефон помимо геттеров будет инкапсулировать свою проверку номера на равенство номеру другого телефона:

namespace app\entities\Employee\Phone;

use Assert\Assertion;

use yii\db\ActiveRecord;

class Phone

{

private $country;

private $code;

private $number;

public function __construct($country, $code, $number)

{

Assertion::notEmpty($country);

Assertion::notEmpty($code);

Assertion::notEmpty($number);

$this->country = $country;

$this->code = $code;

$this->number = $number;

}

public function isEqualTo(self $phone)

{

return $this->getFull() === $phone->getFull();

}

public function getFull()

{

return '+' . $this->country . ' (' . $this->code . ') ' . $this->number;

}

public function getCountry() { return $this->country; }

public function getCode() { return $this->code; }

public function getNumber() { return $this->number; }

}

Далее напишем объекты для доменных событий, на которые потом сможем навешивать рассылку уведомлений, подписку на корпоративную SMS-рассылку и прочие вещи. Некоторым пригодится только идентификатор сотрудника:

namespace app\entities\Employee\Events;

use app\entities\Employee\EmployeeId;

class EmployeeCreated

{

public $employeeId;

public function __construct(EmployeeId $employeeId)

{

$this->employeeId = $employeeId;

}

}

А другим нужно будет передавать и изменившуюся запчасть:

class EmployeePhoneAdded

{

public $employeeId;

public $phone;

public function __construct(EmployeeId $employeeId, Phone $phone)

{

$this->employeeId = $employeeId;

$this->phone = $phone;

}

}

В итоге код нашего агрегата Entity со всей собственной бизнес-логикой окажется таким:

namespace app\entities\Employee;

use app\entities\AggregateRoot;

use app\entities\Employee\Events;

use app\entities\EventTrait;

class Employee implements AggregateRoot

{

use EventTrait;

private $id;

private $name;

private $address;

private $phones;

private $createDate;

private $statuses = [];

public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)

{

$this->id = $id;

$this->name = $name;

$this->address = $address;

$this->phones = new Phones($phones);

$this->createDate = new \DateTimeImmutable();

$this->addStatus(Status::ACTIVE, $this->createDate);

$this->recordEvent(new Events\EmployeeCreated($this->id));

}

public function rename(Name $name)

{

$this->name = $name;

$this->recordEvent(new Events\EmployeeRenamed($this->id, $name));

}

public function changeAddress(Address $address)

{

$this->address = $address;

$this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address));

}

public function addPhone(Phone $phone)

{

$this->phones->add($phone);

$this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));

}

public function removePhone($index)

{

$phone = $this->phones->remove($index);

$this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone));

}

public function archive(\DateTimeImmutable $date)

{

if ($this->isArchived()) {

throw new \DomainException('Employee is already archived.');

}

$this->addStatus(Status::ARCHIVED, $date);

$this->recordEvent(new Events\EmployeeArchived($this->id, $date));

}

public function reinstate(\DateTimeImmutable $date)

{

if (!$this->isArchived()) {

throw new \DomainException('Employee is not archived.');

}

$this->addStatus(Status::ACTIVE, $date);

$this->recordEvent(new Events\EmployeeReinstated($this->id, $date));

}

public function remove()

{

if (!$this->isArchived()) {

throw new \DomainException('Cannot remove active employee.');

}

$this->recordEvent(new Events\EmployeeRemoved($this->id));

}

public function isActive()

{

return $this->getCurrentStatus()->isActive();

}

public function isArchived()

{

return $this->getCurrentStatus()->isArchived();

}

private function getCurrentStatus()

{

return end($this->statuses);

}

private function addStatus($value, \DateTimeImmutable $date)

{

$this->statuses[] = new Status($value, $date);

}

public function getId() { return $this->id; }

public function getName() { return $this->name; }

public function getPhones() { return $this->phones->getAll(); }

public function getAddress() { return $this->address; }

public function getCreateDate() { return $this->createDate; }

public function getStatuses() { return $this->statuses; }

}

Полный код всех классов также доступен в репозитории.

В нашем примере для исключений мы везде используем DomainException:

if ($item->isEqualTo($phone)) {

throw new \DomainException('Phone already exists.');

}

и в тестах проверяем всё по сообщению:

$this->expectExceptionMessage('Phone already exists.');

Вместо этого как для событий мы можем создать собственные классы и для доменных исключений:

class PhoneAlreadyExistsException extends \DomainException

{

public function __construct(Phone $phone)

{

parent::__construct('Phone ' . $phone->getFull() . ' already exists.');

}

}

и вместо DomainException использовать уже их в коде:

if ($item->isEqualTo($phone)) {

throw new PhoneAlreadyExistsException($phone);

}

и в тестах:

$this->expectException(PhoneAlreadyExistsException::class);

Это сделает код выразительнее облегчит написание конструкций try { ... } catch, если для разных ошибок нужна разная обработка. И, заодно, позволит легко изменять текст ошибки.

Все классы написаны. Запускаем тесты снова:

vendor/bin/codecept run unit entities

и добиваемся их прохождения:

Unit Tests (16) --------------------------------------------

ArchiveTest: Success (0.01s)

ArchiveTest: Already archived (0.00s)

ChangeAddressTest: Success (0.00s)

CreateTest: Success (0.00s)

CreateTest: Without phones (0.00s)

CreateTest: With same phone numbers (0.00s)

PhoneTest: Add (0.00s)

PhoneTest: Add exists (0.00s)

PhoneTest: Remove (0.00s)

PhoneTest: Remove not exists (0.00s)

PhoneTest: Remove last (0.00s)

ReinstateTest: Success (0.00s)

ReinstateTest: Not archived (0.00s)

RemoveTest: Success (0.00s)

RemoveTest: Not archived (0.00s)

RenameTest: Success (0.00s)

------------------------------------------------------------


Time: 177 ms, Memory: 10.00MB


OK (16 tests, 58 assertions)

В итоге получим готовый доменный агрегат Employee с набором своих частей:

entities

├── Employee

│   ├── Events

│   │   ├── EmployeeCreated.php

│   │   ├── EmployeeRenamed.php

│   │   ├── EmployeeAddressChanged.php

│   │   ├── EmployeeArchived.php

│   │   ├── EmployeeReinstated.php

│   │   ├── EmployeePhoneAdded.php

│   │   ├── EmployeePhoneRemoved.php

│   │   └── EmployeeRemoved.php

│   ├── Employee.php

│   ├── EmployeeId.php

│   ├── Name.php

│   ├── Address.php

│   ├── Phones.php

│   ├── Phone.php

│   └── Status.php

├── AggregateRoot.php

├── EventTrait.php

└── Id.php

tests

└── unit

└── entities

└── Employee

├── EmployeeBuilder.php

├── CreateTest.php

├── RenameTest.php

├── ChangeAddressTest.php

├── ArchiveTest.php

├── ReinstateTest.php

├── PhoneTest.php

└── RemoveTest.php

Теперь имеем полноценный спроектированный объект, написанный строго по всем требованиям предметной области, описанным в задании заказчика. При этом все процессы точно смоделированы в коде и проверены в тестах.

На протяжении процесса моделирования мы просто сочиняли код объектами любой структуры и любой вложенности. И вообще не думали над тем, что и как мы будем сохранять в БД и какую базу выберем (MySQL, MongoDB, либо будем просто хранить в файлах). И нам сейчас без разницы, как будем хранить даты (в DATETIME или TIMESTAMP) или телефоны (в отдельной таблице phones или в поле employees.phones_json). И нам сейчас даже без разницы, какой будем использовать фреймворк.

Если бы мы напрямую использовали ActiveRecord, то не могли бы себе позволить не думать о полях в БД и вместо программирования целыми днями метались бы по StackOverflow с вопросом «Как сохранить поле в JSON в ActiveRecord?».

Наш Employee не содержит ни одной строки по работе с БД, поэтому сам сохраняться не умеет. Вместо этого нам необходимо придумать некий объект хранилища EmployeeRepository. Им мы и займёмся в следующей части:

Часть 2: Сервисный слой, контроллеры и репозитории

P.S. Открыт набор участников на большой мастер-класс по разработке полноценного интернет-магазина по лучшим практикам на Yii2, о котором многие из вас меня просили.

Так же в этом разделе:
 
MyTetra Share v.0.52
Яндекс индекс цитирования