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

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

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

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

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

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

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

Database-First

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

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

Плюсы:

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

Минусы:

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

Code-First

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

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

Плюсы:

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

Минусы:

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

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

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

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

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

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

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

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

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

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

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

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

$employee = new Employee(
    new EmployeeId(25),
    new Name('Пупкин', 'Василий', 'Петрович'),
    new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
    [
        new Phone(7, '920', '00000001'),
        new Phone(7, '910', '00000002'),
    ]
);

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

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

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

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

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

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

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

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

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

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

namespace tests\unit\entities\Employee;
 
use app\entities\Employee\Address;
use app\entities\Employee\Employee;
use app\entities\Employee\EmployeeId;
use app\entities\Employee\Name;
use app\entities\Employee\Phone;
use Codeception\Test\Unit;
 
class CreateTest extends Unit
{
    public function testSuccess()
    {
        $employee = new Employee(
            $id = new EmployeeId(25),
            $name = new Name('Пупкин', 'Василий', 'Петрович'),
            $address = new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
            $phones = [
                new Phone(7, '920', '00000001'),
                new Phone(7, '910', '00000002'),
            ]
        );
 
        $this->assertEquals($id, $employee->getId());
        $this->assertEquals($name, $employee->getName());
        $this->assertEquals($address, $employee->getAddress());
        $this->assertEquals($phones, $employee->getPhones());
    }
}

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

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

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

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

class CreateTest extends Unit
{
    public function testSuccess()
    {
        $employee = new Employee(
            $id = new EmployeeId(25),
            $name = new Name('Пупкин', 'Василий', 'Петрович'),
            $address = new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
            $phones = [
                new Phone(7, '920', '00000001'),
                new Phone(7, '910', '00000002'),
            ]
        );
 
        $this->assertEquals($id, $employee->getId());
        $this->assertEquals($name, $employee->getName());
        $this->assertEquals($address, $employee->getAddress());
        $this->assertEquals($phones, $employee->getPhones());
 
        $this->assertNotNull($employee->getCreateDate());
 
        $this->assertTrue($employee->isActive());
 
        $this->assertCount(1, $statuses = $employee->getStatuses());
        $this->assertTrue(end($statuses)->isActive());
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeeCreated::class, end($events));
    }
}

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

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

class CreateTest extends Unit
{
    ...
 
    public function testWithoutPhones()
    {
        $this->expectExceptionMessage('Employee must contain at least one phone.');
 
        new Employee(
            new EmployeeId(25),
            new Name('Пупкин', 'Василий', 'Петрович'),
            new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
            []
        );
    }
 
    public function testWithSamePhoneNumbers()
    {
        $this->expectExceptionMessage('Phone already exists.');
 
        new Employee(
            new EmployeeId(25),
            new Name('Пупкин', 'Василий', 'Петрович'),
            new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
            [
                new Phone(7, '920', '00000001'),
                new Phone(7, '920', '00000001'),
            ]
        );
    }
}

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

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

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

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

namespace tests\unit\entities\Employee;
 
use app\entities\Employee\Address;
use app\entities\Employee\Employee;
use app\entities\Employee\EmployeeId;
use app\entities\Employee\Name;
use app\entities\Employee\Phone\Phone;
 
class EmployeeBuilder
{
    private $id = 1;
    private $phones = [];
    private $archived = false;
 
    public function __construct()
    {
        $this->phones[] = new Phone(7, '000', '00000000');
    }
 
    public static function instance()
    {
        return new self();
    }
 
    public function withId($id)
    {
        $this->id = $id;
        return $this;
    }
 
    public function withPhones(array $phones)
    {
        $this->phones = $phones;
        return $this;
    }
 
    public function archived()
    {
        $this->archived = true;
        return $this;
    }
 
    public function build()
    {
        $employee = new Employee(
            new EmployeeId($this->id),
            new Name('Пупкин', 'Василий', 'Петрович'),
            new Address('Россия', 'Липецкая обл.', 'г. Пушкин', 'ул. Ленина', 25),
            $this->phones
        );
        if ($this->archived) {
            $employee->archive(new \DateTimeImmutable());
        }
        return $employee;
    }
}

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

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

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

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

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

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

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

class RenameTest extends Unit
{
    public function testSuccess()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $employee->rename($name = new Name('New', 'Test', 'Name'));
        $this->assertEquals($name, $employee->getName());
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeeRenamed::class, end($events));
    }
}

и адреса:

class ChangeAddressTest extends Unit
{
    public function testSuccess()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $employee->changeAddress($address = new Address('New', 'Test', 'Address', 'Street', '25a'));
        $this->assertEquals($address, $employee->getAddress());
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeeAddressChanged::class, end($events));
    }
}

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

class ArchiveTest extends Unit
{
    public function testSuccess()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $this->assertTrue($employee->isActive());
        $this->assertFalse($employee->isArchived());
 
        $employee->archive($date = new \DateTimeImmutable('2011-06-15'));
 
        $this->assertFalse($employee->isActive());
        $this->assertTrue($employee->isArchived());
 
        $this->assertNotEmpty($statuses = $employee->getStatuses());
        $this->assertTrue(end($statuses)->isArchived());
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeeArchived::class, end($events));
    }
 
    public function testAlreadyArchived()
    {
        $employee = EmployeeBuilder::instance()->archived()->build();
 
        $this->expectExceptionMessage('Employee is already archived.');
        $employee->archive(new \DateTimeImmutable('2011-06-15'));
    }
}

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

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

class PhoneTest extends Unit
{
    public function testAdd()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $employee->addPhone($phone = new Phone(7, '888', '00000001'));
 
        $this->assertNotEmpty($phones = $employee->getPhones());
        $this->assertEquals($phone, end($phones));
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeePhoneAdded::class, end($events));
    }
 
    public function testAddExists()
    {
        $employee = EmployeeBuilder::instance()
            ->withPhones([$phone = new Phone(7, '888', '00000001')])
            ->build();
 
        $this->expectExceptionMessage('Phone already exists.');
 
        $employee->addPhone($phone);
    }
 
    public function testRemove()
    {
        $employee = EmployeeBuilder::instance()
            ->withPhones([
                new Phone(7, '888', '00000001'),
                new Phone(7, '888', '00000002'),
            ])
            ->build();
 
        $this->assertCount(2, $employee->getPhones());
 
        $employee->removePhone(1);
 
        $this->assertCount(1, $employee->getPhones());
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeePhoneRemoved::class, end($events));
    }
 
    public function testRemoveNotExists()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $this->expectExceptionMessage('Phone not found.');
 
        $employee->removePhone(42);
    }
 
    public function testRemoveLast()
    {
        $employee = EmployeeBuilder::instance()
            ->withPhones([
                new Phone(7, '888', '00000001'),
            ])
            ->build();
 
        $this->expectExceptionMessage('Cannot remove the last phone.');
 
        $employee->removePhone(0);
    }
}

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

class RemoveTest extends Unit
{
    public function testSuccess()
    {
        $employee = EmployeeBuilder::instance()->archived()->build();
 
        $employee->remove();
 
        $this->assertNotEmpty($events = $employee->releaseEvents());
        $this->assertInstanceOf(EmployeeRemoved::class, end($events));
    }
 
    public function testNotArchived()
    {
        $employee = EmployeeBuilder::instance()->build();
 
        $this->expectExceptionMessage('Cannot remove active employee.');
 
        $employee->remove();
    }
}

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

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

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

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

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

vendor/bin/codecept run unit entities

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

Unit Tests (16) --------------------------------------------
E ArchiveTest: Success (0.01s)
E ArchiveTest: Already archived (0.00s)
E ChangeAddressTest: Success (0.00s)
 CreateTest: Success (0.00s)
 CreateTest: Without phones (0.00s)
E CreateTest: With same phone numbers (0.00s)
E PhoneTest: Add (0.00s)
E PhoneTest: Add exists (0.00s)
E PhoneTest: Remove (0.00s)
E PhoneTest: Remove not exists (0.00s)
E PhoneTest: Remove last (0.00s)
E ReinstateTest: Success (0.00s)
E ReinstateTest: Not archived (0.00s)
E RemoveTest: Success (0.00s)
E RemoveTest: Not archived (0.00s)
E RenameTest: Success (0.00s)
------------------------------------------------------------

Time: 175 ms, Memory: 8.00MB

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

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

class PhoneTest extends Unit
{
    public function testIsEqual()
    {
        $phone1 = new Phone(7, '920', '0000001');
        $phone2 = new Phone(7, '920', '0000001');        
        $this->assertTrue($phone1->isEqualTo($phone2));
    }
 
    public function testIsNotEqual()
    {
        $phone1 = new Phone(7, '920', '0000001');
        $phone2 = new Phone(7, '900', '0000002');        
        $this->assertFalse($phone1->isEqualTo($phone2));
    }
}

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

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

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

namespace app\entities\Employee;
 
class Employee
{    
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones) { ... }
 
    public function rename(Name $name) { ... }
 
    public function changeAddress(Address $address) { ... }
 
    public function addPhone(Phone $phone) { ... }
 
    public function removePhone($index) { ... }
 
    public function archive(\DateTimeImmutable $date) { ... }
 
    public function reinstate(\DateTimeImmutable $date) { ... }
 
    public function remove() { ... }
 
    public function isActive() { ... }    
    public function isArchived() { ... }
 
    public function getId() { return $this->id; }
    public function getName() { return $this->name; }
    public function getPhones() { return $this->phones; }
    public function getAddress() { return $this->address; }
    public function getCreateDate() { return $this->createDate; }
    public function getStatuses() { return $this->statuses; }
}

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

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

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

class Employee
{
    private $id;
    private $name;
    private $address;
    private $phones = [];
    private $createDate;
    private $statuses = [];
 
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)
    {    
        if (!$phones) {
            throw new \DomainException('Employee must contain at least one phone.');
        }    
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = [];
        $this->createDate = new \DateTimeImmutable();
        $this->addStatus(Status::ACTIVE, $this->createDate);        
        foreach ($phones as $phone) {
            foreach ($this->phones as $current) {
                if ($current->isEqualTo($phone)) {
                    throw new \DomainException('Phone already exists.');                 
                }
            }
            $this->phones[] = $phone;
        }
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    ...
 
    private function addStatus($value, \DateTimeImmutable $date)
    {
        $this->statuses[] = new Status($value, $date);
    }
}

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

class Employee
{
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = new \DateTimeImmutable();
        $this->addStatus(Status::ACTIVE, $this->createDate);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    ...
 
    private function addStatus($value, \DateTimeImmutable $date)
    {
        $this->statuses[] = new Status($value, $date);
    }
}

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

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

namespace app\entities\Employee;
 
use app\entities\Employee\Events;
 
class Employee
{
    ...
 
    public function rename(Name $name)
    {
        $this->name = $name;
        $this->recordEvent(new Events\EmployeeRenamed($this->id, $name));
    }
 
    public function changeAddress(Address $address)
    {
        $this->address = $address;
        $this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address));
    }
 
    ...
}

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

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

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

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

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

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

class Employee
{
    private $events = [];
 
    protected function recordEvent($event)
    {
        $this->events[] = $event;
    }
 
    public function releaseEvents()
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
 
    ...
}

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

namespace app\entities;
 
interface AggregateRoot
{
    public function getId();
 
    public function releaseEvents();
}

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

namespace app\entities;
 
trait EventTrait
{
    private $events = [];
 
    protected function recordEvent($event)
    {
        $this->events[] = $event;
    }
 
    public function releaseEvents()
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}

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

namespace app\entities\Employee;
 
use app\entities\AggregateRoot;
use app\entities\EventTrait;
 
class Employee implements AggregateRoot
{
    use EventTrait;
 
    ...
}

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

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

class Employee implements AggregateRoot
{
    use EventTrait;
 
    private $statuses = [];
 
    ...
 
    public function archive(\DateTimeImmutable $date)
    {
        if ($this->isArchived()) {
            throw new \DomainException('Employee is already archived.');
        }
        $this->addStatus(Status::ARCHIVED, $date);
        $this->recordEvent(new Events\EmployeeArchived($this->id, $date));
    }
 
    public function reinstate(\DateTimeImmutable $date)
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Employee is not archived.');
        }
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeReinstated($this->id, $date));
    }
 
    public function isActive()
    {
        return $this->getCurrentStatus()->isActive();
    }
 
    public function isArchived()
    {
        return $this->getCurrentStatus()->isArchived();
    }
 
    private function getCurrentStatus()
    {
        return end($this->statuses);
    }
 
    ...
}

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

class Employee implements AggregateRoot
{   
    ...
 
    public function remove()
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Cannot remove active employee.');
        }
        $this->recordEvent(new Events\EmployeeRemoved($this->id));
    }
 
    ...
}

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

class Employee implements AggregateRoot
{
    use EventTrait;
 
    private $phones;
 
    ...
 
    public function addPhone(Phone $phone)
    {
        $this->phones->add($phone);
        $this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));
    }
 
    public function removePhone($index)
    {
        $phone = $this->phones->remove($index);
        $this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone));
    }
 
    ...    
 
    public function getPhones() { return $this->phones->getAll(); }
}

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

namespace app\entities\Employee;
 
class Phones
{
    private $phones = [];
 
    public function __construct(array $phones)
    {
        if (!$phones) {
            throw new \DomainException('Employee must contain at least one phone.');
        }
        foreach ($phones as $phone) {
            $this->add($phone);
        }
    }
 
    public function add(Phone $phone)
    {
        foreach ($this->phones as $item) {
            if ($item->isEqualTo($phone)) {
                throw new \DomainException('Phone already exists.');
            }
        }
        $this->phones[] = $phone;
    }
 
    public function remove($index)
    {
        if (!isset($this->phones[$index])) {
            throw new \DomainException('Phone not found.');
        }
        if (count($this->phones) === 1) {
            throw new \DomainException('Cannot remove the last phone.');
        }
        $phone = $this->phones[$index];
        unset($this->phones[$index]);
        return $phone;
    }
 
    public function getAll()
    {
        return $this->phones;
    }
}

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

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

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

на такой:

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

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

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

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

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

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

composer require beberlei/assert

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

Assertion::notEmpty($id);

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

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

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

namespace app\entities;
 
use Assert\Assertion;
 
abstract class Id
{
    protected $id;
 
    public function __construct($id = null)
    {
        Assertion::notEmpty($id);
 
        $this->id = $id;
    }
 
    public function getId()
    {
        return $this->id;
    }
 
    public function isEqualTo(self $other)
    {
        return $this->getId() === $other->getId();
    }
}

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

namespace app\entities\Employee;
 
use app\entities\Id;
 
class EmployeeId extends Id
{
 
}

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

namespace app\entities\Employee;
 
use Assert\Assertion;
 
class Name
{
    private $last;
    private $first;
    private $middle;
 
    public function __construct($last, $first, $middle)
    {
        Assertion::notEmpty($last);
        Assertion::notEmpty($first);
 
        $this->last = $last;
        $this->first = $first;
        $this->middle = $middle;
    }
 
    public function getFull()
    {
        return trim($this->last . ' ' . $this->first . ' ' . $this->middle);
    }
 
    public function getFirst() { return $this->first; }
    public function getMiddle() { return $this->middle; }
    public function getLast() { return $this->last; }
}

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

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

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

namespace app\entities\Employee;
 
use Assert\Assertion;
 
class Address
{
    private $country;
    private $region;
    private $city;
    private $street;
    private $house;
 
    public function __construct($country, $region, $city, $street, $house)
    {
        Assertion::notEmpty($country);
        Assertion::notEmpty($city);
 
        $this->country = $country;
        $this->region = $region;
        $this->city = $city;
        $this->street = $street;
        $this->house = $house;
    }
 
    public function getCountry() { return $this->country; }
    public function getRegion() { return $this->region; }
    public function getCity() { return $this->city; }
    public function getStreet() { return $this->street; }
    public function getHouse() { return $this->house; }
}

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

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

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

namespace app\entities\Employee;
 
use Assert\Assertion;
 
class Status
{
    const ACTIVE = 'active';
    const ARCHIVED = 'archived';
 
    private $value;
    private $date;
 
    public function __construct($value, \DateTimeImmutable $date)
    {
        Assertion::inArray($value, [
            self::ACTIVE,
            self::ARCHIVED
        ]);
 
        $this->value = $value;
        $this->date = $date;
    }
 
    public function isActive()
    {
        return $this->value === self::ACTIVE;
    }
 
    public function isArchived()
    {
        return $this->value === self::ARCHIVED;
    }
 
    public function getValue() { return $this->value; }
    public function getDate() { return $this->date; }
}

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

namespace app\entities\Employee\Phone;
 
use Assert\Assertion;
use yii\db\ActiveRecord;
 
class Phone
{
    private $country;
    private $code;
    private $number;
 
    public function __construct($country, $code, $number)
    {
        Assertion::notEmpty($country);
        Assertion::notEmpty($code);
        Assertion::notEmpty($number);
 
        $this->country = $country;
        $this->code = $code;
        $this->number = $number;
    }
 
    public function isEqualTo(self $phone)
    {
        return $this->getFull() === $phone->getFull();
    }
 
    public function getFull()
    {
        return '+' . $this->country . ' (' . $this->code . ') ' . $this->number;
    }
 
    public function getCountry() { return $this->country; }
    public function getCode() { return $this->code; }
    public function getNumber() { return $this->number; }
}

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

namespace app\entities\Employee\Events;
 
use app\entities\Employee\EmployeeId;
 
class EmployeeCreated
{
    public $employeeId;
 
    public function __construct(EmployeeId $employeeId)
    {
        $this->employeeId = $employeeId;
    }
}

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

class EmployeePhoneAdded
{
    public $employeeId;
    public $phone;
 
    public function __construct(EmployeeId $employeeId, Phone $phone)
    {
        $this->employeeId = $employeeId;
        $this->phone = $phone;
    }
}

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

namespace app\entities\Employee;
 
use app\entities\AggregateRoot;
use app\entities\Employee\Events;
use app\entities\EventTrait;
 
class Employee implements AggregateRoot
{
    use EventTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = new \DateTimeImmutable();
        $this->addStatus(Status::ACTIVE, $this->createDate);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    public function rename(Name $name)
    {
        $this->name = $name;
        $this->recordEvent(new Events\EmployeeRenamed($this->id, $name));
    }
 
    public function changeAddress(Address $address)
    {
        $this->address = $address;
        $this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address));
    }
 
    public function addPhone(Phone $phone)
    {
        $this->phones->add($phone);
        $this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));
    }
 
    public function removePhone($index)
    {
        $phone = $this->phones->remove($index);
        $this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone));
    }
 
    public function archive(\DateTimeImmutable $date)
    {
        if ($this->isArchived()) {
            throw new \DomainException('Employee is already archived.');
        }
        $this->addStatus(Status::ARCHIVED, $date);
        $this->recordEvent(new Events\EmployeeArchived($this->id, $date));
    }
 
    public function reinstate(\DateTimeImmutable $date)
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Employee is not archived.');
        }
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeReinstated($this->id, $date));
    }
 
    public function remove()
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Cannot remove active employee.');
        }
        $this->recordEvent(new Events\EmployeeRemoved($this->id));
    }
 
    public function isActive()
    {
        return $this->getCurrentStatus()->isActive();
    }
 
    public function isArchived()
    {
        return $this->getCurrentStatus()->isArchived();
    }
 
    private function getCurrentStatus()
    {
        return end($this->statuses);
    }
 
    private function addStatus($value, \DateTimeImmutable $date)
    {
        $this->statuses[] = new Status($value, $date);
    }
 
    public function getId() { return $this->id; }
    public function getName() { return $this->name; }
    public function getPhones() { return $this->phones->getAll(); }
    public function getAddress() { return $this->address; }
    public function getCreateDate() { return $this->createDate; }
    public function getStatuses() { return $this->statuses; }
}

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

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

if ($item->isEqualTo($phone)) {
    throw new \DomainException('Phone already exists.');
}

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

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

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

class PhoneAlreadyExistsException extends \DomainException
{
    public function __construct(Phone $phone)
    {
        parent::__construct('Phone ' . $phone->getFull() . ' already exists.');
    }
}

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

if ($item->isEqualTo($phone)) {
    throw new PhoneAlreadyExistsException($phone);
}

и в тестах:

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

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

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

vendor/bin/codecept run unit entities

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

Unit Tests (16) --------------------------------------------
 ArchiveTest: Success (0.01s)
 ArchiveTest: Already archived (0.00s)
 ChangeAddressTest: Success (0.00s)
 CreateTest: Success (0.00s)
 CreateTest: Without phones (0.00s)
 CreateTest: With same phone numbers (0.00s)
 PhoneTest: Add (0.00s)
 PhoneTest: Add exists (0.00s)
 PhoneTest: Remove (0.00s)
 PhoneTest: Remove not exists (0.00s)
 PhoneTest: Remove last (0.00s)
 ReinstateTest: Success (0.00s)
 ReinstateTest: Not archived (0.00s)
 RemoveTest: Success (0.00s)
 RemoveTest: Not archived (0.00s)
 RenameTest: Success (0.00s)
------------------------------------------------------------

Time: 177 ms, Memory: 10.00MB

OK (16 tests, 58 assertions)

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

entities
├── Employee
│   ├── Events
│   │   ├── EmployeeCreated.php
│   │   ├── EmployeeRenamed.php
│   │   ├── EmployeeAddressChanged.php
│   │   ├── EmployeeArchived.php
│   │   ├── EmployeeReinstated.php
│   │   ├── EmployeePhoneAdded.php
│   │   ├── EmployeePhoneRemoved.php
│   │   └── EmployeeRemoved.php
│   ├── Employee.php
│   ├── EmployeeId.php
│   ├── Name.php
│   ├── Address.php
│   ├── Phones.php
│   ├── Phone.php
│   └── Status.php
├── AggregateRoot.php
├── EventTrait.php
└── Id.php
tests
└── unit
    └── entities
        └── Employee
            ├── EmployeeBuilder.php
            ├── CreateTest.php
            ├── RenameTest.php
            ├── ChangeAddressTest.php
            ├── ArchiveTest.php
            ├── ReinstateTest.php
            ├── PhoneTest.php
            └── RemoveTest.php

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

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

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

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

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

Комментарии

 

Леша

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

Ответить

 

Алко

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

Ответить

 

Добрый сосед

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

Ответить

 

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

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

Ответить

 

AntonZ

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

Ответить

 

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

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

Ответить

 

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

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

Ответить

 

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

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

Ответить

 

Александр Макаров

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

Ответить

 

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

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

Ответить

 

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

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

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

Ответить

 

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

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

Ответить

 

Андрей

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

Ответить

 

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

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

Ответить

 

Кирилл

А по yii2? :)

Ответить

 

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

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

Ответить

 

xfg

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

Ответить

 

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

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

Ответить

 

xfg

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

Ответить

 

Александр Макаров

Не для всех, но для многих. Например, гидрация без задействования конструктора широко используется в 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();
}
Ответить

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

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


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



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