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

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

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

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

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

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

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

Database-First

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

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

Плюсы:

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

Минусы:

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

Code-First

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

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

Плюсы:

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

Минусы:

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

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

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

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

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

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

{
    id: 25
    name: {
        last: 'Пупкин',
        first: 'Василий',
        middle: 'Петрович',
    },
    address: {
        country: 'Россия',
        state: 'Липецкая обл.',
        city: 'г. Пушкин',
        street: 'ул. Ленина',
        house: 25
    }
    phones: [
        {country: 7, code: 920, number: 0000001},
        {country: 7, code: 910, number: 0000002},
    ],
    create_date: '2016-04-12 12:34:00',
    current_status = 'active',
    statuses: [
        {status: 'active', date: '2016-04-12 12:34:07'},
        {status: 'archive', date: '2016-04-13 12:56:23'},
        {status: 'active', date: '2016-04-16 14:02:10'},
    ];
}

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

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

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

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

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

$employee = new Employee(
    new 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.

Комментарии

 

Леша

Ух, круто, и все по шагам

Ответить

 

Алко

Чувствую себя плывущим в каком то теплом Гольфстриме. Как только передо мной встает какая то задача, или я задумываюсь о какой то проблематике сам - на тебе, тут же по теме новая статья/вебинар от Дмитрия. Жизнь то налаживается! ))))

Ответить

 

Добрый сосед

Спасибо. Познавательно. А события на все изменения сущности генерировать? Или только те, которые нам нужны будут?

Ответить

 

Дмитрий Елисеев

Которые нужны.

Ответить

 

AntonZ

Спасибо, отличная статья! Помогли понять некоторые вещи про агрегаты. А когда этот агрегат будет сохраняться, будет записываться состояние или события (ES)?

Ответить

 

Дмитрий Елисеев

Здесь будет просто состояние. В ES события немного другие.

Ответить

 

Семенов Максим

Хорошая статья, спасибо!
Дмитрий, а почему вы на хабре не дублируете контент?

Ответить

 

Дмитрий Елисеев

На Хабре нельзя дублировать контент.

Ответить

 

Александр Макаров – rmcreative.ru

Формально — нет, но за хороший свой контент никто не карает, даже если он опубликован где-то ещё...

Ответить

 

Александр

Теперь можно

> Не следует копировать на «Хабр» тексты, опубликованные другими людьми на других ресурсах, но можно копировать собственные тексты, если они не нарушают правила ресурса.

Ответить

 

Патрик Фельдеш

Интереснее всего будет почитать следующую статью, как будет реализована работа с базой данных, что у вас будет спрятано за репозиториями? Свой слой допустим на кверибилдере + свои дата мапперы - пробовали, много кода и взрыв мозга когда доходит до сохранения связей. AR - тоже сомнительно, все равно мапперы писать. Доктрина - не совсем понятно как сохранять коллекции если они у вас на своих классах.

Ответить

 

Руслан Самолетов

Тоже жду следующую статью. Сейчас не понятно как создать и сохранить сущность.

Например моя сущность Request содержит связь с сущностью Company. С клиента мне приходит company_id, но для создания Request же нужна вся сущность Company, а не только id. Иначе это уже не агрегат, а обычный класс отражающий таблицу в БД.

Ответить

 

Дмитрий Елисеев

Если от соседнего агрегата Company нужен только id, то и сохраняйте company_id.

Ответить

 

Андрей

Очень-очень круто, Дима. А когда (ну хоть примерно) готовится мастер-класс по интернет-магазину?

Ответить

 

Дмитрий Елисеев

Ближе к концу апреля. Надо будет успеть все уроки отрепетировать.

Ответить

 

Кирилл

А по yii2? :)

Ответить

 

Дмитрий Елисеев

P.S. к статье прочтите.

Ответить

 

xfg

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

Ответить

 

Дмитрий Елисеев

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

Ответить

 

xfg

Я предполагаю, что вы будете использовать newInstanceWithoutConstructor но это не общее решение для всех ооп языков. В javascript или даже typescript такой возможности нет. Читают вас не только php разработчики. Было бы замечательно, если бы вы делали отступления и предлагали решение в случае невозможности использовать силу магической рефлексии.

Ответить

 

Александр Макаров – rmcreative.ru

Не для всех, но для многих. Например, гидрация без задействования конструктора широко используется в Java.

Ответить

 

anton

Дмитрий, вы пишите сначала тесты на несуществующие методы, в таком случае ide будет ругаться. Возможно удобнее сначала добавить пустые методы в агрегатор - ide не ругается, автокомплит доступен? Есть ли другое решение данной проблемы?

Ответить

 

Дмитрий Елисеев

Да, на ходу придумываю несуществующие методы. Зачем мне здесь автокомплит?

Ответить

 

anton

Потому что каждый метод тестируется несколько раз, можно допустить ошибку (даже при копипасте теста) и не заметить ее сразу.

Ответить

 

Дмитрий Елисеев

Потом при написании кода все опечатки и ошибки так и так найдутся по подсказкам IDE и по красным тестам.

Ответить

 

Glagola

Благодарю за столь детальную статью! Есть пара вопросов:

1) вы везде кидаете ошибку \DomainException с разным текстом, а как вы потом в action'ах контроллера вы понимаете к какому свойству формы привязать данную ошибку? или, например, если к вам в Application service пришел запрос на несуществующую запись, вы кидаете что-то типа \DomainException('Not found'), которую, по хорошему нужно преобразовать в \yii\web\NotFoundHttpException.

2) по поводу коллекции Phones:
2.1) Это все-таки не коллекция, а множество (т.е. хранить два одинаковых номера бессмысленно)
2.2) Рекомендую применять типизированные коллекции (в последнем PHP-дайджисте была ссылка на крутую статью.

Ответить

 

anton

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

Если говорить о yii2, то это будет form models наследованные от обычной модели.

Ответить

 

Glagola

Т.е. получается мы дублируем абсолютно всю валидацию и в домене и в экшенах/"формах/моделях" (в терминологии yii)?

Ответить

 

Дмитрий Елисеев

Не всю, а только самую критичную вроде required и unique.

Ответить

 

Дмитрий Елисеев

Можно и всю остальную типизацию из PHP 7.

Ответить

 

Юрий

Спасибо
Все круто

Ответить

 

Юрий

Дмитрий, в очередной раз спасибо.
У Вас реально талант к понятному объяснению!

Ответить

 

Denis Klimenko

Спасибо!))

Ответить

 

xfg

Не хочется в каждом методе application сервисов писать try catch чтобы выловить DomainException от доменного слоя. Это можно как-то упросить? Может быть вызывать доменный слой не напрямую, а через proxy pattern?

Ответить

 

Анатолий Белов

Спасибо! То, что нужно!
Таких статей мало.

Ответить

 

Сергей

Как создавать и сохранять сущность Employee если в БД id это автоинкрементное целочисленное поле?

Ответить

 

Дмитрий Елисеев

Как вариант, при сохранении пустое значение в объекте $id через рефлексию подменять на автоинкрементное из базы. Либо использовать отдельную таблицу (или секвенцию) для генерации идентификаторов до сохранения сущности. А вообще для независимости от баз и с ориентацией на клиентские приложения с offline-mode вместо автоинкрементов вручную генерируют уникальные UUID.

Ответить

 

Sufir

Позволю себе поделиться, я решил эту проблему при помощи билдера, получилось неплохо: https://habrahabr.ru/post/321340/
Это в том случае, если нет возможности отказаться от автоинкремента, конечно. А так лучше рассмотреть альтернативные варианты: UUID или последовательности, как указал Дмитрий.

Ответить

 

Владимир

Отличная статья. Только что заметил, уведомление о статье не пришло на почту, хотя, вроде бы, подписан на блог.

Ответить

 

Дмитрий Елисеев

Посмотрел. Подписаны только на вебинары.

Ответить

 

Владимир

А, не знал что это две разные подписки. Подпишусь на плюшки еще.

Ответить

 

Александр

а можно в конструктор Employee передавать другую entity?

Например, тот же телефон может быть, например, служебным телефоном который выдают сотрудникам, который сам по себе может содержать
* номер
* модель аппарата
* лимит
и прочие поля

P.S. забыл добавить, что телефон имеет тоже свой id и его тоже нужно сохранять где-то
или, не дай бог, телефон - тоже какой-то агрегат

Ответить

 

Дмитрий Елисеев

Для экономии ресурсов можно передать только его id.

Ответить

 

Sufir

Я бы передавал сущность Телефон, это, например, гарантирует что он существует в системе, да и код изящнее получается, API более говорящее.

// Вместо:
$employee ->changePhoneId(PhoneIdentity(random()));
// Лучше
$employee ->changePhone($phone);


А в самом объекте хранил бы только ID:

function changePhoneId(Phone $phone)
{
    $this->phoneId = $phone->getId();
}
Ответить

 

я просто мимо крокодил

1. Вы предложили свою модель транзакционности доменных событий. А как Вы обеспечиваете её во вложенных агрегатах? Если событие в Employee порождает событие в Address (предположим, это тоже агрегат), то как потом всё это дерево событий по всем агрегатам коммитить?

2. > Помимо встроенных событий внутри агрегата должен быть некий идентификатор вроде нашего EntityId для первичного ключа
> Объект статуса будет содержать методы isActive и isArchived для работы аналогичных методов класса Entity:
> В итоге код нашего агрегата Entity со всей собственной бизнес-логикой окажется таким:
в статье неоднократно, похоже, по ошибке вместо Employee используется Entity

Ответить

 

Дмитрий Елисеев

1. Агрегаты обычно ссылаются друг на друга по id и сохраняются своими репозиториями. Если же нужно именно сделать вложенный, то можно переопределить метод releaseEvents(), чтобы он собирал события всех внутреностей.

3. Исправил.

Ответить

 

Аноним

Если после сохранения сущности, но перед обработкой событий произойдет ошибка, которая приведет к аварийному завершению программы (допустим, аппаратный сбой), получается, события будут потеряны? Пример:

$entity = new Entity(...); // Создаём сущность
$entity->addItem(...); // и производим все операции.
$repository->save($entity); // Сначала сохраняем в БД,

// фатальная ошибка

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

Как бы вы обрабатывали эту ситуацию?

Ответить

 

Дмитрий Елисеев

А такие аппаратные сбои у Вас часто случаются?

Ответить

 

Аноним

Нечасто, но я их привел только в качестве примера. А вот в процессе обработки событий фатальные ошибки по вине программистов случаются регулярно. И даже внутри $repository->save() после успешного сохранения иногда бывали. Понятно, что самое лучшее решение - не допускать ошибок, но хотелось бы иметь какую-то страховку на случай, если они все же произойдут.

Ответить

 

Дмитрий Елисеев

Тогда можно помещать события в очередь, а потом в фоне уже их обрабатывать. Тогда они не будут мешать основному потоку.

Ответить

 

Артем

Дмитрий, а разве это правильно, что доменный слой использует сторонние расширения, типа Assertion?

Ответить

 

Дмитрий Елисеев

Нормально. Это самодостаточная независимая библиотека.

Ответить

 

Артем

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

Почему конкретно эту библиотеку можно использовать напрямую из доменного слоя?

Ответить

 

Дмитрий Елисеев

Эта библиотека не инфраструктурная. Побочных эффектов и любой иной зависимости от инфраструктуры у неё нет.

Ответить

 

xfg

Моё мнение при вызове метода агрегата должно генерироваться строго одно доменное событие. Доменное событие призвано отображать случившее действие. Соответственно будет странно, если действие генерирует более одного доменного события. Поэтому я считаю здесь https://github.com/ElisDN/yii2-demo-aggregates/blob/master/entities/EventTrait.php#L11 событие лучше присваивать переменной вместо массива и быть может кидать исключение если происходит попытка перезаписи доменного события. Ваше мнение?

Ответить

 

Дмитрий Елисеев

Посмотрите последний пример с ProductManageService из статьи про композитные формы. Там мы вызываем кучу методов подряд перед тем, как сохранить сущность. Без накопления в массив там не обойтись.

> Моё мнение при вызове метода агрегата должно генерироваться строго одно доменное событие.

Не всегда. Например, метод списания некого количества товара для заказа помимо события списания может дополнительно проверить оставшееся количество и сгенерировать событие что товар закончился:

class Product
{
    public function checkout($quantity)
    {
        if ($quantity < $this->quantity) {
            throw new \DomainException('Недостаточно для заказа.');
        }
        $this->quantity -= $quantity;
        $this->recordEvent(new ProductCheckedOut($this, $quantity));
        if ($this->quantity === 0) {
           $this->recordEvent(new ProductRunnedOut($this));
        }
    }
}
Ответить

 

Maxim

Дмитрий спасибо за статью. Очень доступно излагаете решение. Сам только обучаюсь программированию. На ваш блог набрел в результате поиска в интернете информации о проектировании структуры БД, о том какие вообще существуют наработанные практики взаимодействия кода с хранилищем, а в итоге нашел больше чем искал. Пробую писать тесты, вот только пишу их пока после написания кода, и не было что ли недостающего кусочка картинки или может опыта мало, что бы делать наоборот, а тут живой пример разработки через тестирование. В общем всех благ и добра Вам! Продолжайте пожалуйста.

Ответить

 

Kirill Nagovitsyn

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

Ответить

 

Дмитрий Елисеев

Можно передавать из сервиса:

$employee->addPhone($phone, $this->limit);
Ответить

 

Виталий

Вопрос по поводу валидации сотрудника. Если перед созданием сотрудника нужно применять какие-либо проверки на уникальность с уже сохраненными в базе сотрудниками, куда их пихать? в конструктор сотрудника или в сервис выше?

Ответить

 

Дмитрий Елисеев

В сервис:

class UserService
{
    public function signup(SignupForm $form)
    {
        if ($this->users->existsByEmail($form->email)) {
            throw new \DomainException('Пользователь уже есть.');
        }
        $user = new User(
            $form->email,
            $this->hasher->hash($form->password)
        );
        $this->users->add($user);
    }
}
Ответить

 

Виталий

Тогда любой сможет сделать

$user = new User(
    $form->email,
    $this->hasher->hash($form->password)
);
$this->users->add($user);

и созданить пользователя минуя валидацию(

Ответить

 

Дмитрий Елисеев

Обсуждали здесь.

Ответить

 

Сергей

Здравствуйте! Какой смысл в использовании проверок Assertion... в примере ниже? Ведь если при вызове метода ему не будут переданы аргументы (empty) ошибка выскочит и выполнение кода до Assertion... не дойдет.

public function __construct(int $country, string $code, string $number)
{
	Assertion::notEmpty($country);
	Assertion::notEmpty($code);
	Assertion::notEmpty($number);
Ответить

 

Дмитрий Елисеев

Если передать пустые строки:

new Phone(7, '', '');

то ошибка не выскочит.

Ответить

 

Виталий

class Employee очень большой, и скорее всего в реальном приложении будет рости. Как бы вы предложили решить эту проблему? Я слышал о Bounded Context в DDD. Можно ли его применить для решения этой проблемы? Или это переусложнит систему? Спасибо.

Ответить

 

Дмитрий Елисеев

Да, можно разбить сущность по ответственностям на независимые контексты, как мы разбивали User на части в статье про независимые модули.

Ответить

 

Ilya

Почему мы не делаем build статическим?

Ответить

 

Максим Б

Добрый день! Спасибо за столь обширные труды, которыми вы делитесь. Всегда кодил, начиная именно с создания таблиц в БД. Сейчас попробовал code first, очень понравилось ))) Полностью свободный полет мысли )
И возник вопрос. Нормально ли это, если 2 объекта будут содержать друг друга как свойства классов? Допустим, у меня есть регионы и города. Регион содержит массив объектов городов. Но город должен "знать" , к какому он относится региону. Нет ли чего плохого в том, что в классе город будет свойство класса регион? (Тот же самый объект, в массиве которого содержится этот город). Или нужен другой подход?

Ответить

 

Радислав

Может ли объект значение содержать в себе сущность или это говорит о том что бы пересмотреть структуру доменной модели?
К примеру сущность сущность Склад содержит в себе список объектов-значений Запас который представлен свойствами Продукт и Количество где количество это объект-значение а продукт сущность.

есть у меня и другой вариант:
По причине того что склад один, выпилить сущность Склад и хоанить количество продуктов на складе непосредственно в Продукте.
Но что-то мне подсказывает что первый вариант предпочтительнее.

Ответить

 

yk

статья "как сделать с мухи слона"
конечно, если за этот пздц платят, то можно и так сделать

Ответить

 

pzd

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

Ответить

 

Иван

Дмитрий, добрый день. хотел спросить, почему для всех внутренних свойств вы делаете объект, даже для id?
чем плох метод c обычной фабрикой?

public static function factory($id, $data)
{
    $employee = new self();
    $employee->set_id($id);
    $employee->set_phones($data['phones']);
    // ...
    // проверки опущены
    return $employee;
}
Ответить

 

Дмитрий Елисеев

> Дмитрий, добрый день. хотел спросить, почему для всех внутренних свойств вы делаете объект, даже для id?

Чтобы в них помещать их логику и чтобы пользоваться типизацией.

> чем плох метод c обычной фабрикой?

Ничем, если конструктор сделать приватным.
Если потребуется сделать два конструктора, то сделаем их двумя фабриками.

Ответить

 

Николай М

Наш Employee не содержит ни одной строки по работе с БД
На самом деле содержит :-D Кое-где в статье затесалась строка :-D

use yii\db\ActiveRecord;
Ответить

 

Дмитрий – dmitrylee.ru

А перезапуск интенсива по ООП планируется?

Ответить

 

Дмитрий Елисеев

Может когда-нибудь перезапущу. Но это лучше делать после выхода 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.

Мы пишем код, минимально привязанный к конкретному фреймворку. А никак не универсальный для разных проектов.

Ответить

 

Дмитрий – dmitrylee.com

Дмитрий, вопрос такого плана. Если у нескольких сущностей есть похожий объект значение, например Статусы, идентификатор скорее всего им не нужен, нужно в каждом агрегате создавать свой или можно вынести за пределы и где тогда его хранить? В принципе можно сделать общий интерефейс.

Ответить

 

Дмитрий Елисеев

Как удобнее. Если это действительно что-то одинаковое вроде Email, то можно и вынести в общую папку. Если же это статусы, то они со временем могут начать отличаться. Тогда удобнее у каждой сущности иметь свой статус.

Ответить

 

V V

Дмитрий, спасибо за разбор материала. Спустя 5 лет почти все актуально.
Интересует момент с эвентами.

Спустя пару лет после статьи вышел PSR-14 и в нем Event Dispatcher диспатчит только по 1 эвенту. Что посоветуете для того, чтобы обработать массив эвентов? foreach + свой wrapper?

Какое лучшее место в рамках ДДД для того, чтобы подписаться на нужные эвенты? Предвижу сотни-тысячи подписчиков в средних/крупных проектах. Если это делать при бутстрапинге, то как-то страшно становится.

Как бы Вы обработали эвенты, которые лучше всего положить в очередь для дальнейшей обработки? В эвент лисенере просто класть этот же эвент в очередь (в сериализованном виде) или создавать новый тип эвента (не доменный)?

Ответить

 

Дмитрий Елисеев

> Спустя пару лет после статьи вышел PSR-14 и в нем Event Dispatcher диспатчит только по 1 эвенту. Что посоветуете для того, чтобы обработать массив эвентов? foreach + свой wrapper?

Да, можно foreach.

Ответить

 

Дмитрий Елисеев

> Предвижу сотни-тысячи подписчиков в средних/крупных проектах. Если это делать при бутстрапинге, то как-то страшно становится.

Если используете напрямую EventDispatcher, то да, инициализировать все сразу при бутстрапинге диспетчера или подгружать лениво при первом вызове его метода dispatch.

Ответить

 

Дмитрий Елисеев

> Какое лучшее место в рамках ДДД для того, чтобы подписаться на нужные эвенты?

> Как бы Вы обработали эвенты, которые лучше всего положить в очередь для дальнейшей обработки? В эвент лисенере просто класть этот же эвент в очередь (в сериализованном виде) или создавать новый тип эвента (не доменный)?

По DDD при модульном монолите или микросервисах вместо использования простого синхронного EventDispatcher желательно вообще все события отправлять в очереди от своего модуля или сервиса. И там события маршрутизировать по очередям так, чтобы каждый воркер слушал только свою очередь и на пришедшее событие имел только один обработчик.

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

Стандартный EventDispatcher так маршрутизировать события по разным очередям не умеет. Маршрутизировать умеет RabbitMQ. Так что можно сделать свой класс, публикующий событие в нужный exchange:

class EventEmitter
{
    ...

    public function dispatch($event)
    {
        $exchange = $this->detectExchangeForEvent($event);
        $this->amqp->push($exchange, $event);
    }
}

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

Такой подход гарантирует надёжную независимую обработку событий на принимающей стороне.

А для гарантии отправки с очередь событий на публикующей стороне используют паттерн Outbox, когда события сначала в одной транзакции одновременно с сущностью сохраняют в свою таблицу events в БД и только потом отправляют в очередь. Тогда если после сохранения сущности подключение к RabbitMQ отвалится, то оставшиеся в БД повторно дошлются в него по Cron. Но так есть риск, что одно событие уйдёт в очередь дважды.

При таком подходе нужно делать идемпотентные слушатели, что бы если что они не выполняли одно и то же повторно. Например, при отправке в очередь к каждому событию дописывать его уникальный id из таблицы events. И в каждом консьюмере в сохранять идентификаторы обработанных событий, чтобы обрабатывать только уникальные.

Такое мы сделаем в проекте аукциона.

Ответить

 

flashid flashid

Дмитрий, а почему в билдере мы именно клонируем его?

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>