Проектирование сущностей предметной области
На почту люди пишут «Куда пропал?» Активно готовлю большой мастер-класс по интернет-магазину и иногда обитаю на форуме. Так там некоторые разработчики порой недоумевают, как можно программировать на фреймворках без использования CRUD и ActiveRecord, и почему такую «лёгкую» на первый взгляд прямую работу с полями в базе данных недолюбливают тру-ООП-шники, предпочитающие DDD.
Да и многие спрашивают, что в тестах нужно тестировать, а что не нужно. И нужно ли проверять приватные методы или нет? До этого сегодня как раз дойдём, а пока сравним...
Подходы к разработке
При разработке чего-то на фреймворке у нас есть выбор: либо полностью всё программировать средствами этого каркаса, либо делать более-менее чистое независимое ядро с доменной логикой на чистом PHP и подключать к фреймворку через обвязку в виде неких абстрактных адаптеров.
В первом варианте мы становимся жёстко завязаны на внутренности фреймворка, а во втором получаем неограниченную гибкость разработки и большую свободу действий.
Недопонимание между двумя лагерями программистов возникают из-за наличия диаметрально противоположных подходов к разработке: Database-First и Code-First.
Database-First
Здесь мы начинаем разработку с базы данных:
- Проектируем структуру БД;
- Создаём таблицы в базе;
- Генерируем модели данных;
- Генерируем стандартный CRUD;
- Вписываем логику куда-попало.
Плюсы:
- Быстрая генерация кода;
- Отсутствие лишних преобразований;
- Идеален для конвейерных проектов.
Минусы:
- Жёсткая привязка к таблицам;
- Плоские модели данных;
- Типы полей совпадают с типами колонок;
- Костыли с добавлением логики;
- Невозможность изменения таблиц без изменения кода;
- Сложность тестирования без БД.
Code-First
А здесь мы сначала пишем код ядра, и только потом привязываем БД:
- Продумываем бизнес-логику;
- При желании пишем unit-тесты;
- Программируем сущности и сервисы;
- Привязываем базу данных.
Плюсы:
- Идеален для индивидуальной разработки;
- При написании классов не думаем о БД вообще;
- Чистый ООП без костылей;
- Использование любых типов полей;
- Получаем рабочий код без БД;
- Возможность незаметного изменения структуры БД;
Минусы:
- Необходимость написания конвертеров из БД в объект и обратно.
В этой статье мы пойдём вторым путём, так как первый известен практически всем из простых примеров в документации.
Постановка задачи
В демонстрационных целях возьём третий пример из второго урока интенсива по ООП с небольшим дополнением. Там разобрано около тридцати примеров за шесть дней, так что возьмём только небольшую часть. Почти как и там спроектируем сущность сотрудника компании примерно по такому заданию:
Сотрудник должен содержать имя, адрес, телефоны, дату создания и признак того, активен он сейчас или его дело спрятано в архив. Телефонов может быть несколько, но обязательно должен быть хотя бы один. И в его деле неоходимо хранить историю помещения его в архив и восстановления. По номеру телефона будут определять страну, слать на него 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 Id(25), new \DateTimeImmutable(), 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
. Если когда-нибудь они начнут отличаться, то просто уберём наследование.
Для явной типизации идентификаторов мы также придумали некий пользовательский тип Employee\Id
. Его задача – хранить идентификатор и следить за тем, чтобы он не был пустым и не менялся. В качестве его значения мы можем использовать либо автоинкрементные числа из секвенций базы данных, либо UUID. Но пока мы только придумываем наружный вид классов и их внутренности нам не важны.
В остальном никаких особенностей пока нет.
Моделирование сущности через написание тестов
Начнём изучать требования и продумывать работу с Employee
.
Создание сотрудника мы можем формализовать в простом юнит-тесте:
namespace tests\unit\entities\Employee; use app\entities\Employee\Address; use app\entities\Employee\Employee; use app\entities\Employee\Id; use app\entities\Employee\Name; use app\entities\Employee\Phone; use Codeception\Test\Unit; class CreateTest extends Unit { public function testSuccess(): void { $employee = new Employee( $id = new Id(25), $date = new \DateTimeImmutable(), $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($date, $employee->getCreateDate()); $this->assertEquals($name, $employee->getName()); $this->assertEquals($address, $employee->getAddress()); $this->assertEquals($phones, $employee->getPhones()); } }
Пока мы просто создаём объект и проверяем правильность его заполнения.
Обратим внимание на код создания идентификатора:
$id = new Id(25);
Использование числовых идентификаторов нам привычно, но есть нюансы. Мы при создании сущности Employee уже должны передать Id. Но при использовании автоинкрементного ключа в базе данных MySQL мы получить идентификатор до вставки записи не сможем.
Работать с заранее известным идентификатором вместо автоинкремента удобнее, так как его можно присваивать, передавать в объекты событий вроде
new EmployeeCreated($this->id)
в конструкторе классаEmployee
, использовать в путях загрузки файлов или куда-то записывать уже ДО сохранения объекта в базу данных. Если же значение заранее не известно, то так сделать мы не сможем.
В PostgreSQL это можно сделать, так как там для генерации последовательных идентификаторов используются отдельные от основных таблиц секвенции. Но что делать в общем случае? И что если у нас будет не один сервер базы данных с одной секвенцией, а несколько? Да и не все хранилища поддерживают автоинктементную генерацию первичных ключей.
Для решения этих вопросов удобнее отказаться от автоинрементного числового идентификатора и перейти на использование уникальных рандомных идентификаторов. Но простой uniqid()
не подойдёт, так как он короткий и на миллионах записей будет очень много повторов. Поэтому лучше использовать GUID – продвинутый алгоритм генерации всемирно уникальных строковых идентификаторов.
Чтобы не сочинять алгоритм генерации вручную установим готовую библиотеку:
composer require ramsey/uuid
и будем генерировать ей рандомные строки:
$id = new Id(Uuid::uuid4()->toString());
Но такую строку везде повторять неудобно:
$id = new Id(Uuid::uuid4()->toString());
Удобнее добавить статический метод-фабрику:
$id = Id::next();
и везде в тестах использовать такой вызов.
Помимо передачи значений в конструткор Employee у нас ещё должна быть некая логика, что в объекте при конструировании:
- сотрудник становится активным;
- сохраняется история статусов;
- генерируется событие
EmployeeCreated
.
Дополним наш тест этими проверками:
class CreateTest extends Unit { public function testSuccess(): void { $employee = new Employee( $id = Id::next(), $date = new \DateTimeImmutable(), $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($date, $employee->getCreateDate()); $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(): void { $this->expectExceptionMessage('Employee must contain at least one phone.'); new Employee( Id::next(), new \DateTimeImmutable(), new Name('Пупкин', 'Василий', 'Петрович'), new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25), [] ); } public function testWithSamePhoneNumbers(): void { $this->expectExceptionMessage('Phone already exists.'); new Employee( Id::next(), new \DateTimeImmutable(), 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\Id; use app\entities\Employee\Name; use app\entities\Employee\Phone\Phone; class EmployeeBuilder { private $id; private $date; private $name; private $address; private $phones = []; private $archived = false; public function __construct() { $this->id = Id::next(); $this->date = new \DateTimeImmutable(); $this->name = new Name('Пупкин', 'Василий', 'Петрович'); $this->address = new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25); $this->phones[] = new Phone(7, '000', '00000000'); } public function withId(Id $id): self { $clone = clone $this; $clone->id = $id; return $clone; } public function withPhones(array $phones): self { $clone = clone $this; $clone->phones = $phones; return $clone; } public function archived(): self { $clone = clone $this; $clone->archived = true; return $clone; } public function build(): Employee { $employee = new Employee( $this->id, $this->date, $this->name, $this->address, $this->phones ); if ($this->archived) { $employee->archive(new \DateTimeImmutable()); } return $employee; } }
С его помощью можно создать сотрудника со значениями по умолчанию:
$employee = (new EmployeeBuilder())->build();
и сделали вспомогательные методы withId
, withPhones
и archived
, чтобы можно было при необходимости подменять значения:
$employee1 = (new EmployeeBuilder())->withId(new Id('id-1'))->build(); $employee2 = (new EmployeeBuilder())->withId(new Id('id-2'))->build();
Этот построитель мы и будем теперь использовать для проверки смены имени:
class RenameTest extends Unit { public function testSuccess(): void { $employee = (new EmployeeBuilder())->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(): void { $employee = (new EmployeeBuilder())->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(): void { $employee = (new EmployeeBuilder())->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(): void { $employee = (new EmployeeBuilder())->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(): void { $employee = (new EmployeeBuilder())->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(): void { $employee = (new EmployeeBuilder()) ->withPhones([$phone = new Phone(7, '888', '00000001')]) ->build(); $this->expectExceptionMessage('Phone already exists.'); $employee->addPhone($phone); } public function testRemove(): void { $employee = (new EmployeeBuilder()) ->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(): void { $employee = (new EmployeeBuilder())->build(); $this->expectExceptionMessage('Phone is not found.'); $employee->removePhone(42); } public function testRemoveLast(): void { $employee = (new EmployeeBuilder()) ->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(): void { $employee = (new EmployeeBuilder())->archived()->build(); $employee->remove(); $this->assertNotEmpty($events = $employee->releaseEvents()); $this->assertInstanceOf(EmployeeRemoved::class, end($events)); } public function testNotArchived(): void { $employee = (new EmployeeBuilder()))->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(): void { $phone1 = new Phone(7, '920', '0000001'); $phone2 = new Phone(7, '920', '0000001'); $this->assertTrue($phone1->isEqualTo($phone2)); } public function testIsNotEqual(): void { $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(Id $id, DateTimeImmutable $date, Name $name, Address $address, array $phones) { ... } public function rename(Name $name): void { ... } public function changeAddress(Address $address): void { ... } public function addPhone(Phone $phone): void { ... } public function removePhone($index): void { ... } public function archive(\DateTimeImmutable $date): void { ... } public function reinstate(\DateTimeImmutable $date): void { ... } public function remove(): void { ... } public function isActive(): bool { ... } public function isArchived(): bool { ... } public function getId(): Id { return $this->id; } public function getName(): Name { return $this->name; } public function getPhones(): array { return $this->phones; } public function getAddress(): Address { return $this->address; } public function getCreateDate(): \DateTimeImmutable { return $this->createDate; } public function getStatuses(): array { return $this->statuses; } }
Займёмся теперь реализацией внутренностей. Попробуем реализовать класс Employee
и его внутренние объекты.
Реализация классов
Сначала реализуем конструктор, принимающий обязательные аргументы и инициализирующий объект текущей датой и активным статусом:
class Employee { private $id; private $name; private $address; private $phones = []; private $createDate; private $statuses = []; public function __construct(Id $id, \DateTimeImmutable $date, 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 = $date; $this->addStatus(Status::ACTIVE, $date); 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): void { $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(Id $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): void { $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): void { $this->name = $name; $this->recordEvent(new Events\EmployeeRenamed($this->id, $name)); } public function changeAddress(Address $address): void { $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): void { $this->events[] = $event; } public function releaseEvents(): array { $events = $this->events; $this->events = []; return $events; } ... }
Такой код нам понадобится во всех агрегатах. Удобно классифицировать все корни агрегатов неким обобщённым интерфейсом:
namespace app\entities; interface AggregateRoot { public function releaseEvents(); }
и к этому интерфейсу приложить трейт с реализацией работы с $events
:
namespace app\entities; trait EventTrait { private $events = []; protected function recordEvent($event): void { $this->events[] = $event; } public function releaseEvents(): array { $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): void { 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): void { 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(): bool { return $this->getCurrentStatus()->isActive(); } public function isArchived(): bool { return $this->getCurrentStatus()->isArchived(); } private function getCurrentStatus(): Status { return end($this->statuses); } ... }
Потом метод для генерации события удаления либо отмены этого процесса:
class Employee implements AggregateRoot { ... public function remove(): void { 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): void { $this->phones->add($phone); $this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone)); } public function removePhone($index): void { $phone = $this->phones->remove($index); $this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone)); } ... public function getPhones(): array { 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): void { foreach ($this->phones as $item) { if ($item->isEqualTo($phone)) { throw new \DomainException('Phone already exists.'); } } $this->phones[] = $phone; } public function remove($index): Phone { 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(): array { return $this->phones; } }
Иначе весь этот код пришлось бы добавить в аналогичные методы класса Employee
.
Также мы немного переделали геттер getPhones
в классе Employee
для получения списка номеров с такого:
public function getPhones(): array { return $this->phones; }
на такой:
public function getPhones(): array { 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\Employee; use Assert\Assertion; use Ramsey\Uuid\Uuid; class Id { private $id; public function __construct(string $id) { Assertion::notEmpty($id); $this->id = $id; } public static function next(): self { return new self(Uuid::uuid4()->toString()); } public function getId(): string { return $this->id; } public function isEqualTo(self $other): bool { return $this->getId() === $other->getId(); } }
Объект для хранения имени будет содержать только базовую валидацию и геттеры, так как имя мы будем рассматривать как неизменяемый объект с полями, доступными только для чтения:
namespace app\entities\Employee; use Assert\Assertion; class Name { private $last; private $first; private $middle; public function __construct(string $last, string $first, ?string $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(): string { return $this->first; } public function getMiddle(): ?string { return $this->middle; } public function getLast(): string { 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(string $country, string $region, string $city, string $street, string $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(): string { return $this->country; } public function getRegion(): string { return $this->region; } public function getCity(): string { return $this->city; } public function getStreet(): string { return $this->street; } public function getHouse(): string { return $this->house; } }
Да, логики здесь почти нет.
Приватные поля из аргументов конструктора и геттеры для них в продвинутых IDE генерируются автоматически. Поэтому визг «жутко много кода» слышен только от отчаянных любителей программирования в Notepad++.
Объект статуса будет содержать методы isActive
и isArchived
для работы аналогичных методов класса Employee
:
namespace app\entities\Employee; use Assert\Assertion; class Status { const ACTIVE = 'active'; const ARCHIVED = 'archived'; private $value; private $date; public function __construct(string $value, \DateTimeImmutable $date) { Assertion::inArray($value, [ self::ACTIVE, self::ARCHIVED ]); $this->value = $value; $this->date = $date; } public function isActive(): bool { return $this->value === self::ACTIVE; } public function isArchived(): bool { return $this->value === self::ARCHIVED; } public function getValue(): string { return $this->value; } public function getDate(): \DateTimeImmutable { return $this->date; } }
Телефон помимо геттеров будет инкапсулировать свою проверку номера на равенство номеру другого телефона:
namespace app\entities\Employee\Phone; use Assert\Assertion; class Phone { private $country; private $code; private $number; public function __construct(int $country, string $code, string $number) { Assertion::notEmpty($country); Assertion::notEmpty($code); Assertion::notEmpty($number); $this->country = $country; $this->code = $code; $this->number = $number; } public function isEqualTo(self $phone): bool { return $this->getFull() === $phone->getFull(); } public function getFull(): string { return '+' . $this->country . ' (' . $this->code . ') ' . $this->number; } public function getCountry(): int { return $this->country; } public function getCode(): string { return $this->code; } public function getNumber(): string { return $this->number; } }
Далее напишем объекты для доменных событий, на которые потом сможем навешивать рассылку уведомлений, подписку на корпоративную SMS-рассылку и прочие вещи. Некоторым пригодится только идентификатор сотрудника:
namespace app\entities\Employee\Events; use app\entities\Employee\Id; class EmployeeCreated { public $employee; public function __construct(Id $employee) { $this->employee = $employee; } }
А другим нужно будет передавать и изменившуюся запчасть:
class EmployeePhoneAdded { public $employee; public $phone; public function __construct(Id $employee, Phone $phone) { $this->employee = $employee; $this->phone = $phone; } }
В итоге код нашего агрегата Employee
со всей собственной бизнес-логикой окажется таким:
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(Id $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): void { $this->name = $name; $this->recordEvent(new Events\EmployeeRenamed($this->id, $name)); } public function changeAddress(Address $address): void { $this->address = $address; $this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address)); } public function addPhone(Phone $phone): void { $this->phones->add($phone); $this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone)); } public function removePhone($index): void { $phone = $this->phones->remove($index); $this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone)); } public function archive(\DateTimeImmutable $date): void { 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): void { 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(): void { if (!$this->isArchived()) { throw new \DomainException('Cannot remove active employee.'); } $this->recordEvent(new Events\EmployeeRemoved($this->id)); } public function isActive(): bool { return $this->getCurrentStatus()->isActive(); } public function isArchived(): bool { return $this->getCurrentStatus()->isArchived(); } private function getCurrentStatus(): Status { return end($this->statuses); } private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses[] = new Status($value, $date); } public function getId(): Id { return $this->id; } public function getName(): Name { return $this->name; } public function getPhones(): array { return $this->phones->getAll(); } public function getAddress(): Address { return $this->address; } public function getCreateDate(): \DateTimeImmutable { return $this->createDate; } public function getStatuses(): array { 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 │ ├── Id.php │ ├── Name.php │ ├── Address.php │ ├── Phones.php │ ├── Phone.php │ └── Status.php ├── AggregateRoot.php └── EventTraitId.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: Сервисный слой, контроллеры и репозитории
И на практике такой подход мы применяем в демо-проекте на Slim и React.
Ух, круто, и все по шагам
Чувствую себя плывущим в каком то теплом Гольфстриме. Как только передо мной встает какая то задача, или я задумываюсь о какой то проблематике сам - на тебе, тут же по теме новая статья/вебинар от Дмитрия. Жизнь то налаживается! ))))
Спасибо. Познавательно. А события на все изменения сущности генерировать? Или только те, которые нам нужны будут?
Которые нужны.
Спасибо, отличная статья! Помогли понять некоторые вещи про агрегаты. А когда этот агрегат будет сохраняться, будет записываться состояние или события (ES)?
Здесь будет просто состояние. В ES события немного другие.
Хорошая статья, спасибо!
Дмитрий, а почему вы на хабре не дублируете контент?
На Хабре нельзя дублировать контент.
Формально — нет, но за хороший свой контент никто не карает, даже если он опубликован где-то ещё...
Теперь можно
> Не следует копировать на «Хабр» тексты, опубликованные другими людьми на других ресурсах, но можно копировать собственные тексты, если они не нарушают правила ресурса.
Интереснее всего будет почитать следующую статью, как будет реализована работа с базой данных, что у вас будет спрятано за репозиториями? Свой слой допустим на кверибилдере + свои дата мапперы - пробовали, много кода и взрыв мозга когда доходит до сохранения связей. AR - тоже сомнительно, все равно мапперы писать. Доктрина - не совсем понятно как сохранять коллекции если они у вас на своих классах.
Тоже жду следующую статью. Сейчас не понятно как создать и сохранить сущность.
Например моя сущность Request содержит связь с сущностью Company. С клиента мне приходит company_id, но для создания Request же нужна вся сущность Company, а не только id. Иначе это уже не агрегат, а обычный класс отражающий таблицу в БД.
Если от соседнего агрегата Company нужен только id, то и сохраняйте company_id.
Очень-очень круто, Дима. А когда (ну хоть примерно) готовится мастер-класс по интернет-магазину?
Ближе к концу апреля. Надо будет успеть все уроки отрепетировать.
А по yii2? :)
P.S. к статье прочтите.
Генерация события EmployeeCreated в конструкторе класса Employee будет срабатывать при каждом восстановлении агрегата из хранилища. Скорее всего это не то, что мы хотим. Вероятнее всего, придется использовать фабричный метод для создания сущности где и генерировать событие EmployeeCreated, а из конструктора убрать, так как конструктор класса будет задействован как при создании нового объекта, так и при восстановлении уже существующего из хранилища.
Это решается неиспользованием конструктора при восстановлении из базы.
Я предполагаю, что вы будете использовать newInstanceWithoutConstructor но это не общее решение для всех ооп языков. В javascript или даже typescript такой возможности нет. Читают вас не только php разработчики. Было бы замечательно, если бы вы делали отступления и предлагали решение в случае невозможности использовать силу магической рефлексии.
Не для всех, но для многих. Например, гидрация без задействования конструктора широко используется в Java.
Дмитрий, вы пишите сначала тесты на несуществующие методы, в таком случае ide будет ругаться. Возможно удобнее сначала добавить пустые методы в агрегатор - ide не ругается, автокомплит доступен? Есть ли другое решение данной проблемы?
Да, на ходу придумываю несуществующие методы. Зачем мне здесь автокомплит?
Потому что каждый метод тестируется несколько раз, можно допустить ошибку (даже при копипасте теста) и не заметить ее сразу.
Потом при написании кода все опечатки и ошибки так и так найдутся по подсказкам IDE и по красным тестам.
Благодарю за столь детальную статью! Есть пара вопросов:
1) вы везде кидаете ошибку \DomainException с разным текстом, а как вы потом в action'ах контроллера вы понимаете к какому свойству формы привязать данную ошибку? или, например, если к вам в Application service пришел запрос на несуществующую запись, вы кидаете что-то типа \DomainException('Not found'), которую, по хорошему нужно преобразовать в \yii\web\NotFoundHttpException.
2) по поводу коллекции Phones:
2.1) Это все-таки не коллекция, а множество (т.е. хранить два одинаковых номера бессмысленно)
2.2) Рекомендую применять типизированные коллекции (в последнем PHP-дайджисте была ссылка на крутую статью.
В экшенах мы используем персональные валидаторы перед тем как выполнять сервисы, эти валидаторы и будут выдавать инфу по ошибкам каждого входного параметра для клиента.
Если говорить о yii2, то это будет form models наследованные от обычной модели.
Т.е. получается мы дублируем абсолютно всю валидацию и в домене и в экшенах/"формах/моделях" (в терминологии yii)?
Не всю, а только самую критичную вроде required и unique.
Можно и всю остальную типизацию из PHP 7.
Спасибо
Все круто
Дмитрий, в очередной раз спасибо.
У Вас реально талант к понятному объяснению!
Спасибо!))
Не хочется в каждом методе application сервисов писать try catch чтобы выловить DomainException от доменного слоя. Это можно как-то упросить? Может быть вызывать доменный слой не напрямую, а через proxy pattern?
Спасибо! То, что нужно!
Таких статей мало.
Как создавать и сохранять сущность Employee если в БД id это автоинкрементное целочисленное поле?
Как вариант, при сохранении пустое значение в объекте $id через рефлексию подменять на автоинкрементное из базы. Либо использовать отдельную таблицу (или секвенцию) для генерации идентификаторов до сохранения сущности. А вообще для независимости от баз и с ориентацией на клиентские приложения с offline-mode вместо автоинкрементов вручную генерируют уникальные UUID.
Позволю себе поделиться, я решил эту проблему при помощи билдера, получилось неплохо: https://habrahabr.ru/post/321340/
Это в том случае, если нет возможности отказаться от автоинкремента, конечно. А так лучше рассмотреть альтернативные варианты: UUID или последовательности, как указал Дмитрий.
Отличная статья. Только что заметил, уведомление о статье не пришло на почту, хотя, вроде бы, подписан на блог.
Посмотрел. Подписаны только на вебинары.
А, не знал что это две разные подписки. Подпишусь на плюшки еще.
а можно в конструктор Employee передавать другую entity?
Например, тот же телефон может быть, например, служебным телефоном который выдают сотрудникам, который сам по себе может содержать
* номер
* модель аппарата
* лимит
и прочие поля
P.S. забыл добавить, что телефон имеет тоже свой id и его тоже нужно сохранять где-то
или, не дай бог, телефон - тоже какой-то агрегат
Для экономии ресурсов можно передать только его id.
Я бы передавал сущность Телефон, это, например, гарантирует что он существует в системе, да и код изящнее получается, API более говорящее.
А в самом объекте хранил бы только ID:
Дополнил статью ответами на мысли и вопросы.
1. Вы предложили свою модель транзакционности доменных событий. А как Вы обеспечиваете её во вложенных агрегатах? Если событие в Employee порождает событие в Address (предположим, это тоже агрегат), то как потом всё это дерево событий по всем агрегатам коммитить?
2. > Помимо встроенных событий внутри агрегата должен быть некий идентификатор вроде нашего EntityId для первичного ключа
> Объект статуса будет содержать методы isActive и isArchived для работы аналогичных методов класса Entity:
> В итоге код нашего агрегата Entity со всей собственной бизнес-логикой окажется таким:
в статье неоднократно, похоже, по ошибке вместо Employee используется Entity
1. Агрегаты обычно ссылаются друг на друга по id и сохраняются своими репозиториями. Если же нужно именно сделать вложенный, то можно переопределить метод releaseEvents(), чтобы он собирал события всех внутреностей.
3. Исправил.
Если после сохранения сущности, но перед обработкой событий произойдет ошибка, которая приведет к аварийному завершению программы (допустим, аппаратный сбой), получается, события будут потеряны? Пример:
Как бы вы обрабатывали эту ситуацию?
А такие аппаратные сбои у Вас часто случаются?
Нечасто, но я их привел только в качестве примера. А вот в процессе обработки событий фатальные ошибки по вине программистов случаются регулярно. И даже внутри $repository->save() после успешного сохранения иногда бывали. Понятно, что самое лучшее решение - не допускать ошибок, но хотелось бы иметь какую-то страховку на случай, если они все же произойдут.
Тогда можно помещать события в очередь, а потом в фоне уже их обрабатывать. Тогда они не будут мешать основному потоку.
Дмитрий, а разве это правильно, что доменный слой использует сторонние расширения, типа Assertion?
Нормально. Это самодостаточная независимая библиотека.
Не уверен, что это довод, чтобы использовать любую библиотеку в доменном слое.
Из этой логики я могу использовать любую инфраструктурную библиотеку (которая является самодостаточной), при этом у меня доменный слой становится зависимым от инфраструктуры.
Почему конкретно эту библиотеку можно использовать напрямую из доменного слоя?
Эта библиотека не инфраструктурная. Побочных эффектов и любой иной зависимости от инфраструктуры у неё нет.
Моё мнение при вызове метода агрегата должно генерироваться строго одно доменное событие. Доменное событие призвано отображать случившее действие. Соответственно будет странно, если действие генерирует более одного доменного события. Поэтому я считаю здесь https://github.com/ElisDN/yii2-demo-aggregates/blob/master/entities/EventTrait.php#L11 событие лучше присваивать переменной вместо массива и быть может кидать исключение если происходит попытка перезаписи доменного события. Ваше мнение?
Посмотрите последний пример с ProductManageService из статьи про композитные формы. Там мы вызываем кучу методов подряд перед тем, как сохранить сущность. Без накопления в массив там не обойтись.
> Моё мнение при вызове метода агрегата должно генерироваться строго одно доменное событие.
Не всегда. Например, метод списания некого количества товара для заказа помимо события списания может дополнительно проверить оставшееся количество и сгенерировать событие что товар закончился:
Дмитрий спасибо за статью. Очень доступно излагаете решение. Сам только обучаюсь программированию. На ваш блог набрел в результате поиска в интернете информации о проектировании структуры БД, о том какие вообще существуют наработанные практики взаимодействия кода с хранилищем, а в итоге нашел больше чем искал. Пробую писать тесты, вот только пишу их пока после написания кода, и не было что ли недостающего кусочка картинки или может опыта мало, что бы делать наоборот, а тут живой пример разработки через тестирование. В общем всех благ и добра Вам! Продолжайте пожалуйста.
Такой вопрос. Если с обязательностью у сотрудника хотя бы одного номера телефона все понятно, то как правильно организовать проверку на максимальное количество номеров, которое может меняться, например, раз в год и указывается в конфиге? Или, например, оно может быть разным для разных подразделений. Понятно, что проверка должна быть в объекте Phones, но как правильно это самое значение туда передать?
Можно передавать из сервиса:
Вопрос по поводу валидации сотрудника. Если перед созданием сотрудника нужно применять какие-либо проверки на уникальность с уже сохраненными в базе сотрудниками, куда их пихать? в конструктор сотрудника или в сервис выше?
В сервис:
Тогда любой сможет сделать
и созданить пользователя минуя валидацию(
Обсуждали здесь.
Здравствуйте! Какой смысл в использовании проверок Assertion... в примере ниже? Ведь если при вызове метода ему не будут переданы аргументы (empty) ошибка выскочит и выполнение кода до Assertion... не дойдет.
Если передать пустые строки:
то ошибка не выскочит.
class Employee очень большой, и скорее всего в реальном приложении будет рости. Как бы вы предложили решить эту проблему? Я слышал о Bounded Context в DDD. Можно ли его применить для решения этой проблемы? Или это переусложнит систему? Спасибо.
Да, можно разбить сущность по ответственностям на независимые контексты, как мы разбивали User на части в статье про независимые модули.
Почему мы не делаем build статическим?
Добрый день! Спасибо за столь обширные труды, которыми вы делитесь. Всегда кодил, начиная именно с создания таблиц в БД. Сейчас попробовал code first, очень понравилось ))) Полностью свободный полет мысли )
И возник вопрос. Нормально ли это, если 2 объекта будут содержать друг друга как свойства классов? Допустим, у меня есть регионы и города. Регион содержит массив объектов городов. Но город должен "знать" , к какому он относится региону. Нет ли чего плохого в том, что в классе город будет свойство класса регион? (Тот же самый объект, в массиве которого содержится этот город). Или нужен другой подход?
Дмитрий, спасибо! Получил ваш ответ на форуме.
Может ли объект значение содержать в себе сущность или это говорит о том что бы пересмотреть структуру доменной модели?
К примеру сущность сущность Склад содержит в себе список объектов-значений Запас который представлен свойствами Продукт и Количество где количество это объект-значение а продукт сущность.
есть у меня и другой вариант:
По причине того что склад один, выпилить сущность Склад и хоанить количество продуктов на складе непосредственно в Продукте.
Но что-то мне подсказывает что первый вариант предпочтительнее.
статья "как сделать с мухи слона"
конечно, если за этот пздц платят, то можно и так сделать
Вы очень незрелый и посему очень глупый человек, пожалуйста избегайте подобных выводов до своего просветления.
А пока пишите дальше сайты визитки и бложики.
Дмитрий, добрый день. хотел спросить, почему для всех внутренних свойств вы делаете объект, даже для id?
чем плох метод c обычной фабрикой?
> Дмитрий, добрый день. хотел спросить, почему для всех внутренних свойств вы делаете объект, даже для id?
Чтобы в них помещать их логику и чтобы пользоваться типизацией.
> чем плох метод c обычной фабрикой?
Ничем, если конструктор сделать приватным.
Если потребуется сделать два конструктора, то сделаем их двумя фабриками.
Наш Employee не содержит ни одной строки по работе с БД
На самом деле содержит :-D Кое-где в статье затесалась строка :-D
А перезапуск интенсива по ООП планируется?
Может когда-нибудь перезапущу. Но это лучше делать после выхода PHP 7.4.
Мне подумалось, а что если сохранение делать не через репозиторий, а использовать events.
Уже в events'ах собирать в кучу все команды в БД, и потом отправлять их одной пачкой? Используется ли в DDD такой подход?
Есть Event Sourcing, когда мы вместо сохранения сущности храним только поток её событий. Тогда нужно будет для фронтенда делать отдельное хранилище в рамках CQRS и обеспечивать синхронизацию по этим же событиям.
Ну я скорее не о чистом EventSourcing, а именно о его некой "Имитации", когда я работаю с агрегатом, в процессе генерятся евенты (типа: Изменилось поле 1, добавился объект в коллекцию 2 и т.п.), и при вызове save, из всех этих эвентов собирается пачка запросов к СУБД, и прямо пачкой отправляется. Мне кажется, часто это выгоднее чем каждый раз сохранять агрегат целиком со всеми связями. Вот и интересно, делают ли так, может какие подводные камни тут есть.
Event Sourcing в классическом понимании на нашем проекте имплементировать слишком поздно. Да и ни к чему имхо, ибо история изменений не нужна
Решил я почитать все это вдумчиво и вот какой вопрос возник.
Мы хотим по идее написать такой более-менее универсальный код, слабо связанный и переносимый между фреймворками. По идее он бы и между разными проектами переносим должен быть.
И вот например есть у нас \Entity\Status в которой у нас сейчас 2 возможных статуса active и archived. Но вдруг по какой то причине нам понадобилось в одном из проектов добавить еще и статус suspended. Ну вот такая там бизнес-логика.
То есть нам сущность базовую надо будет как то в клиентском коде изменить, чтобы она теперь знала о новом статусе и умела с ним работать. То есть нужно подменить класс \Entity\Status на \Project\Entity\Status. И получается нам в нашей библиотеке все же надо колхозить DI контейнер? Или есть какие другие варианты?
В нужном проекте напрямую добавляем новое значение в его Status.
Мы пишем код, минимально привязанный к конкретному фреймворку. А никак не универсальный для разных проектов.
Дмитрий, вопрос такого плана. Если у нескольких сущностей есть похожий объект значение, например Статусы, идентификатор скорее всего им не нужен, нужно в каждом агрегате создавать свой или можно вынести за пределы и где тогда его хранить? В принципе можно сделать общий интерефейс.
Как удобнее. Если это действительно что-то одинаковое вроде Email, то можно и вынести в общую папку. Если же это статусы, то они со временем могут начать отличаться. Тогда удобнее у каждой сущности иметь свой статус.
Дмитрий, спасибо за разбор материала. Спустя 5 лет почти все актуально.
Интересует момент с эвентами.
Спустя пару лет после статьи вышел PSR-14 и в нем Event Dispatcher диспатчит только по 1 эвенту. Что посоветуете для того, чтобы обработать массив эвентов? foreach + свой wrapper?
Какое лучшее место в рамках ДДД для того, чтобы подписаться на нужные эвенты? Предвижу сотни-тысячи подписчиков в средних/крупных проектах. Если это делать при бутстрапинге, то как-то страшно становится.
Как бы Вы обработали эвенты, которые лучше всего положить в очередь для дальнейшей обработки? В эвент лисенере просто класть этот же эвент в очередь (в сериализованном виде) или создавать новый тип эвента (не доменный)?
> Спустя пару лет после статьи вышел PSR-14 и в нем Event Dispatcher диспатчит только по 1 эвенту. Что посоветуете для того, чтобы обработать массив эвентов? foreach + свой wrapper?
Да, можно foreach.
> Предвижу сотни-тысячи подписчиков в средних/крупных проектах. Если это делать при бутстрапинге, то как-то страшно становится.
Если используете напрямую EventDispatcher, то да, инициализировать все сразу при бутстрапинге диспетчера или подгружать лениво при первом вызове его метода dispatch.
> Какое лучшее место в рамках ДДД для того, чтобы подписаться на нужные эвенты?
> Как бы Вы обработали эвенты, которые лучше всего положить в очередь для дальнейшей обработки? В эвент лисенере просто класть этот же эвент в очередь (в сериализованном виде) или создавать новый тип эвента (не доменный)?
По DDD при модульном монолите или микросервисах вместо использования простого синхронного EventDispatcher желательно вообще все события отправлять в очереди от своего модуля или сервиса. И там события маршрутизировать по очередям так, чтобы каждый воркер слушал только свою очередь и на пришедшее событие имел только один обработчик.
То есть если на одно событие нужно сделать три действия, то в системе должно быть три очереди с тремя слушателями, делающими одно дествие и отдельно подтверждающими получение своего сообщения. И событие должно посылаться сразу во все три эти очереди. Тогда в случае ошибки в одном из слушателей ошибка не сломает запуск других слушателей. При ошибке событие останется в личной очереди и слушатель системой будет перезапущен повторно пока не подтвердит успешную обработку.
Стандартный EventDispatcher так маршрутизировать события по разным очередям не умеет. Маршрутизировать умеет RabbitMQ. Так что можно сделать свой класс, публикующий событие в нужный exchange:
И в RabbitMQ уже конфигурировать маршрутизацию по связкам exchange с разными queue. И конфигурировать обработчики так, чтобы на событие в каждой очереди срабатывал один обработчик в своём консьюмере.
Такой подход гарантирует надёжную независимую обработку событий на принимающей стороне.
А для гарантии отправки с очередь событий на публикующей стороне используют паттерн Outbox, когда события сначала в одной транзакции одновременно с сущностью сохраняют в свою таблицу events в БД и только потом отправляют в очередь. Тогда если после сохранения сущности подключение к RabbitMQ отвалится, то оставшиеся в БД повторно дошлются в него по Cron. Но так есть риск, что одно событие уйдёт в очередь дважды.
При таком подходе нужно делать идемпотентные слушатели, что бы если что они не выполняли одно и то же повторно. Например, при отправке в очередь к каждому событию дописывать его уникальный id из таблицы events. И в каждом консьюмере в сохранять идентификаторы обработанных событий, чтобы обрабатывать только уникальные.
Такое мы сделаем в проекте аукциона.
Дмитрий, а почему в билдере мы именно клонируем его?