Проектирование сущностей предметной области
На почту люди пишут «Куда пропал?» Активно готовлю большой мастер-класс по интернет-магазину и иногда обитаю на форуме. Так там некоторые разработчики порой недоумевают, как можно программировать на фреймворках без использования 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 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 is 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): 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(): EmployeeId { 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(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): 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(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): 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; 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): bool { 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(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; use yii\db\ActiveRecord; 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\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; } }
В итоге код нашего агрегата 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(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): 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(): EmployeeId { 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 │ ├── 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
. Им мы и займёмся в следующей части:
Ух, круто, и все по шагам
Чувствую себя плывущим в каком то теплом Гольфстриме. Как только передо мной встает какая то задача, или я задумываюсь о какой то проблематике сам - на тебе, тут же по теме новая статья/вебинар от Дмитрия. Жизнь то налаживается! ))))
Спасибо. Познавательно. А события на все изменения сущности генерировать? Или только те, которые нам нужны будут?
Которые нужны.
Спасибо, отличная статья! Помогли понять некоторые вещи про агрегаты. А когда этот агрегат будет сохраняться, будет записываться состояние или события (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 обычной фабрикой?