Сервисный слой и контроллеры

Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области Employee со своей собственной бизнес-логикой для описания объектов сотрудников. Теперь нужно как-то работать с ней из контроллера, сохранять в базу данных и доставать обратно. Но наш Employee не содержит ни одной строки по работе с базой данных, поэтому сам сохраняться не умеет. Что же с этим делать?

Обычно в данном случае создают внешний объект хранилища (Repository), который будет управлять сохранением сущностей.

И внутри себя он уже будет содержать SQL-код, сохраняющий весь агрегат с его статусами, телефонами и прочими внутренностями.

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

То есть вещь, работая с которой мы можем добавлять, доставать удалять элементы как будто работаем с объектом Collection:

$employee = $employees->get($id);
$employees->add($employee);
$employees->remove($employee);

Это Collection-Like Repository. Здесь есть добавление, но нет обновления, так как предполагается, что за этим должен следить другой код и актуализовать данные, время от времени посылая запросы в БД.

Но в нашем случае мы можем добавить метод save:

$employee = $employees->get($id);
$employees->add($employee);
$employees->save($employee);
$employees->remove($employee);

в котором будем выполнять UPDATE-запросы. Это уже будет Storage-Like Repository.

И такой EmployeeRepository сможем использовать в нужных нам местах для сохранения сущностей. Например, в сервисах приложения. Что это за сервисы и что они делают?

Сервис уровня приложения

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

В нашем случае для взаимодействия с контроллерами браузера или API нам понадобится некий прикладной сервис EmployeeService, который используя EmployeeRepository и диспетчер событий EventDispatcher осуществлял бы все операции с сущностью сотрудника:

namespace app\services;
 
use app\services\dto\AddressDto;
use app\services\dto\EmployeeArchiveDto;
use app\services\dto\EmployeeCreateDto;
use app\services\dto\EmployeeReinstateDto;
use app\services\dto\NameDto;
use app\services\dto\PhoneDto;
use app\dispatchers\EventDispatcher;
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 app\repositories\EmployeeRepository;
 
class EmployeeService
{
    private $employees;
    private $dispatcher;
 
    public function __construct(EmployeeRepository $employees, EventDispatcher $dispatcher)
    {
        $this->employees = $employees;
        $this->dispatcher = $dispatcher;
    }
 
    public function create(EmployeeCreateDto $dto): void
    {
        $employee = new Employee(
            Id::next(),
            new \DateTimeImmutable(),
            new Name(
                $dto->name->last,
                $dto->name->first,
                $dto->name->middle
            ),
            new Address(
                $dto->address->country,
                $dto->address->region,
                $dto->address->city,
                $dto->address->street,
                $dto->address->house
            ),
            array_map(static function (PhoneDto $phone) {
                return new Phone(
                    $phone->country,
                    $phone->code,
                    $phone->number
                );
            }, $dto->phones)
        );
        $this->employees->add($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function rename(Id $id, NameDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->rename(new Name(
            $dto->last,
            $dto->first,
            $dto->middle
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function changeAddress(Id $id, AddressDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->changeAddress(new Address(
            $dto->country,
            $dto->region,
            $dto->city,
            $dto->street,
            $dto->house
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function addPhone(Id $id, PhoneDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->addPhone(new Phone(
            $dto->country,
            $dto->code,
            $dto->number
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function removePhone(Id $id, $index): void
    {
        $employee = $this->employees->get($id);
        $employee->removePhone($index);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function archive(Id $id, EmployeeArchiveDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->archive($dto->date);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function reinstate(Id $id, EmployeeReinstateDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->reinstate($dto->date);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function remove(Id $id): void
    {
        $employee = $this->employees->get($id);
        $employee->remove();
        $this->employees->remove($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
}

Мы можем передавать большое число параметров каждому методу:

public function changeAddress(Id $id, $country, $region, $city, $street, $house): void
{
    $employee = $this->employees->get($id);
    $employee->changeAddress(new Address(
        $country,
        $region,
        $city,
        $street,
        $house
    ));
    $this->employees->save($employee);
    $this->dispatcher->dispatch($employee->releaseEvents());
}

Но, чтобы не путаться в их порядке и количестве, эти наборы мы собрали в отдельные типизированные структуры передачи данных (Data Transfer Object) вроде AddressDto:

class AddressDto
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
}

и передаём уже их:

public function changeAddress(Id $id, AddressDto $dto): void
{
    $employee = $this->employees->get($id);
    $employee->changeAddress(new Address(
        $dto->country,
        $dto->region,
        $dto->city,
        $dto->street,
        $dto->house
    ));
    $this->employees->save($employee);
    $this->dispatcher->dispatch($employee->releaseEvents());
}

При желании мы можем и сам $id поместить внутрь DTO в поле $dto->id. Это на любителя...

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

Можете посмотреть код нашего сервиса и его DTO на GitHub.

Если ваш проект содержит какой-либо построитель форм поверх объектов (либо если используете компонент Symfony/Forms или подобный), то можете использовать его для генерации форм ввода на основе этих структур. Если же ваш фреймворк не такой умный и не умеет этого делать, то можем в качестве DTO использовать саму модель формы AddressForm вместо AddressDto:

class AddressForm extends \yii\base\Model
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
 
    public function rules(): array { ... }
}
public function changeAddress(Id $id, AddressForm $form)
{
    ...
    $employee->changeAddress(new Address(
        $form->country,
        ...
    ));
    ...
}

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

Если всё же хотите осуществить полную фреймворконезависимость, то можете оставить AddressDto и производить ручную конвертацию данных из формы в этот объект:

class AddressForm extends Model
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
 
    public function rules(): array { ... }
 
    public function getDto(): AddressDto
    {
        $dto = new AddressDto();
        $dto->country = $this->country;
        ...
        return $dto;
    }
}

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

Например, если хотите заполнять дату в форме в виде трёх полей, а в DTO записать уже скомбинированный объект класса DateTimeImmutable, то это будет очень кстати:

class ReinstateForm extends Model
{
    public $year;
    public $mounth;
    public $day;
 
    public function rules(): array { ... }
 
    public function getDto(): ReinstateDto
    {
        $dto = new ReinstateDto();
        $dto->date = DateTimeImmutable::createFromFormat('Y-m-d', $this->year . '-' . $this->mounth . '-' . $this->day);
        return $dto;
    }
}

Так мы становимся полностью отвязанными от специфики реализации форм фреймворка.

Диспетчер событий

Далее нашему сервису понадобится некий диспетчер событий:

namespace app\dispatchers;
 
interface EventDispatcher
{
    public function dispatch(array $events): void;
}

Это штука, в которую мы будем передавать все сгенерированные в нашем ядре события:

$this->dispatcher->dispatch($employee->releaseEvents());

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

namespace app\dispatchers;
 
class DummyEventDispatcher implements EventDispatcher
{
    public function dispatch(array $events)
    {
        foreach ($events as $event) {
            \Yii::info('Dispatch event ' . \get_class($event));
        }
    }
}

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

Заглушку можно пока зарегистрировать в DI-контейнере вашего фреймворка. В Yii, например, это можно сделать прямо добавив секцию container в конфигурационные файлы web.php, console.php и test.php (если работать с yii2-app-basic) или в common/config/main.php (в yii2-app-advanced):

$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'container' => [
        'singletons' => [
            'app\dispatchers\EventDispatcher' => ['app\dispatchers\DummyEventDispatcher'],
        ],
    ]
    'components' => [
        ...
    ],
],

Но дабы это не копипастить, можно сделать отдельный загрузочный класс с настройками контейнера:

namespace app\bootstrap;
 
use app\dispatchers\EventDispatcher;
use app\dispatchers\DummyEventDispatcher;
use yii\base\BootstrapInterface;
 
class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $container = \Yii::$container;
 
        $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class);
    }
}

и в вышеуказанных конфигурационных файлах указать его в секции bootstrap:

$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => [
        'log',
        'app\bootstrap\Bootstrap',
    ],
    'components' => [
        ...
    ],
],

Так этот класс загрузится в момент запуска приложения и сконфигурирует контейнер внедрения зависимостей на внедрение именно объекта заглушки всем сервисам, которые будут требовать интерфейс диспетчера. И с отдельным классом мы не сильно засоряем конфигурационные файлы обилием конструкций use.

Контроллеры

И сейчас, когда разобрались с формами, можно соорудить «браузерный» контроллер:

namespace app\controllers;
 
use app\forms\EmployeeCreateForm;
use app\services\EmployeeService;
use yii\web\Controller;
use Yii
 
class EmployeeController extends Controller
{
    private $employeeService;
 
    public function __construct($id, $module, EmployeeService $employeeService, $config = [])
    {
        $this->employeeService = $employeeService;
        parent::__construct($id, $module, $config);
    }
 
    public function actionCreate()
    {
        $form = new EmployeeCreateForm();
 
        if ($form->load(\Yii::$app->request->post()) && $form->validate()) {
            try {
                $this->employeeService->create($form->getDto());
                Yii::$app->session->setFlash('success', 'Employee is created.');
                return $this->redirect(['index']);
            } catch (\DomainException $e) {
                Yii::$app->errorHandler->logException($e);
                Yii::$app->session->setFlash('error', Yii::t('errors', $e->getMessage()));                
            }
        }
 
        return $this->render('create', [
            'form' => $form,
        ]);
    }
}

На этом шаге часто возникает вопрос вроде «зачем создавать отдельный класс EmployeeService, если его код можно поместить прямо в контроллере«? На него есть несколько ответов.

Во-первых, помимо обычного веб-интерфейса у сайта может быть некое API. И оно будет содержать похожий контроллер:

namespace app\controllers\api;
 
use app\forms\EmployeeCreateForm;
use app\services\EmployeeService;
use yii\rest\Controller;
 
class EmployeeController extends Controller
{
    private $employeeService;
 
    public function __construct($id, $module, EmployeeService $employeeService, $config = [])
    {
        $this->employeeService = $employeeService;
        parent::__construct($id, $module, $config);
    }
 
    public function actionCreate()
    {
        $form = new EmployeeCreateForm();
        $form->load(\Yii::$app->request->getBodyParams(), '');
 
        if (!$form->validate()) {
            return $form;
        }
 
        try {
            $this->employeeService->create($form->getDto());
            Yii::$app->response->setStatusCode(201);
        } catch (\DomainException $e) {
             throw new BadRequestHttpException($e->getMessage(), 0, $e);            
        }
    }
}

И помимо этих двух у нас может появиться консольный контроллер для управления сотрудниками из командной строки (или Ratchet-демон). Если бы у нас не было класса EmployeeService, то код его методов вроде create пришлось бы копировать во все три-четыре контроллера.

Во-вторых, весьма сложно протестировать модульными тестами фрагмент кода внутри контроллера. Пришлось бы имитировать Yii::$app->request и парсить результирующий отрендеренный HTML-ответ. Его бы целиком пришлось проверять только внешними функциональными тестами. А в нашем подходе (когда весь основной код вынесен в отдельные классы) при необходимости можно легко протестировать сам класс EmployeeService также, как мы тестировали Employee.

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

Выносом кода в отдельный класс мы избавились от копирования и облегчили тестирование. Контроллеры у нас оказались практически пустыми. Они только заполняют модели форм данными запроса и вызывают методы сервиса. Вся логика из контроллеров перекочевала в доменную модель (где термин «доменная модель» объединяет вместе все сервисы и сущности). Именно это и имеет в виду принцип «тонкий контроллер и толстая модель» в частности и паттерн «Контроллер» из GRASP в общем, а не «пихайте всё в ActiveRecord».

А то у меня постоянно происходит диалог адептами Yii:

— Куда впихнуть эту груду кода? В ActiveRecord или в контроллер?
— А в чём проблема?
— Говорят, что и толстый контроллер – это плохо, и жирная модель тоже плохо.
— Ни туда, ни туда. Напиши отдельный класс.
— В каком смысле? А от чего его наследовать? От Component? От Model?
— Ни от чего не наследуй. Просто класс.
— А что... так можно?
— Да, во фреймворках можно делать просто классы :)
— Ух-ты... А мужики-то не знают... © Какой-то «Толстяк».

С контроллерами примерно разобрались. Подробнее их изучим в следующих статьях. А пока вернёмся к хранению.

Репозиторий

Судя по исходному коду, нашему EmployeeService нужен некий объект хранилища EmployeeRepository, который будет отвечать за хранение и доставание сущностей. То есть будет с таким интерфейсом:

namespace app\repositories;
 
use app\entities\Employee\Employee;
use app\entities\Employee\Id;
 
interface EmployeeRepository
{
    /**
     * @param Id $id
     * @return Employee
     * @throws NotFoundException
     */
    public function get(Id $id): Employee;
 
    public function add(Employee $employee): void;
 
    public function save(Employee $employee): void;
 
    public function remove(Employee $employee): void;
}

В этих методах мы уже можем производить необходимые SQL-запросы и заполнять объекты.

Методы у нас стандартные: get, add, save и remove. Но в некоторых сервисах нужно осуществлять дополнительные проверки. Например, в методе регистрации пользователя и смены его данных нужно проверять бизнес-требование уникальности имени и адреса электронной почты. Такую проверку в сущность User не поместишь, поэтому её добавим в сам сервис:

class UserService
{
    ...
 
    public function requestSignup($username, $email, $password): void
    {
        $this->guardUsernameIsUnique($username);
        $this->guardEmailIsUnique($email);
        $user = User::requestSignup(
            $username,
            $email,
            $this->passwordHasher->hash($password),
            $this->authTokenizer->generate(),
        );
        $this->users->add($user);
    }
 
    public function changeEmail($userId, $email): void
    {
        $user = $this->users->get($userId);
        $this->guardEmailIsUnique($email, $user->getId());
        $user->changeEmail($email);
        $this->users->save($user);
    }
 
    public function confirmSignup($token): void
    {
        $user = $this->users->getByEmailConfirmToken($token);
        $user->confirmSignup();
        $this->users->save($user);
    }
 
    ...
 
    private function guardUsernameIsUnique($username, $exceptId = null): void
    {
        if ($this->users->existsByUsername($username, $exceptId)) {
            throw new \DomainException('Username already exists');
        }
    }
 
    private function guardEmailIsUnique($email, $exceptId = null): void
    {
        if ($this->users->existsByEmail($email, $exceptId)) {
            throw new \DomainException('Email already exists');
        }
    }
}

Помимо использования вспомогательных доменных сервисов PasswordHasher и AuthTokenizer этот прикладной сервис UserService вызывает у репозитория методы existsByUsername и existsByEmail, чтобы проверить, нет ли там уже других пользователей (исключая текущего) с такими же данными. И может потребовать ещё и реализацию методов getByEmail, getByEmailConfirmToken и подобных. Поэтому классы репозиториев могут быть более обширными.

Пока остановимся на нужном нам базовом наборе методов:

namespace app\repositories;
...
interface EmployeeRepository
{
    /**
     * @param Id $id
     * @return Employee
     * @throws NotFoundException
     */
    public function get(Id $id): Employee;
 
    public function add(Employee $employee): void;
 
    public function save(Employee $employee): void;
 
    public function remove(Employee $employee): void;
}

а любые другие можно добавить при необходимости.

Метод get нам должен либо возвращать запрошенный из базы данных объект, либо кидать исключение, если не нашёл. Создадим сразу класс этого исключения:

namespace app\repositories;
 
class NotFoundException extends \LogicException
{
 
}

и так и оставим его пустым.

Сейчас сделаем примитивный MemoryEmployeeRepository, который будет сохранять записи в приватный массив $items. В следующих частях рассмотрим несколько реализаций репозиториев.

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

Репозитории будут разными. Кто-то будет хранить всё в MySQL скалярными полями или в JSON, кто-то – в другой БД. Можно было бы написать тест, который вызывает метод add() и проверяет все поля в базе и действительно ли там всё записалось в JSON, TIMESTAMP или DATETIME. В итоге на каждый репозиторий будут сотни строк тестов и потом получим проблемы с переписыванием тестов при каждом переименовании поля в базе.

Но не всё ли нам равно, как мы там храним? Мы придумали репозиторий для того, чтобы сохранять и извлекать объекты. Поэтому для нас главное – это записать объект и проверить, что он вернулся оттуда таким же, каким был.

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

namespace tests\unit\repositories;
 
use app\entities\Employee\Id;
use app\entities\Employee\Name;
use app\entities\Employee\Phone;
use app\entities\Employee\Status;
use app\repositories\EmployeeRepository;
use app\repositories\NotFoundException;
use tests\unit\entities\Employee\EmployeeBuilder;
use Codeception\Test\Unit;
 
abstract class BaseRepositoryTest extends Unit
{
    /**
     * @var EmployeeRepository
     */
    protected $repository;
 
    public function testGet(): void
    {
        $this->repository->add($employee = (new EmployeeBuilder())->build());
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertNotNull($found);
        $this->assertEquals($employee->getId(), $found->getId());
    }
 
    public function testGetNotFound(): void
    {
        $this->expectException(NotFoundException::class);
 
        $this->repository->get(new Id(uniqid()));
    }
 
    public function testAdd(): void
    {
        $employee = (new EmployeeBuilder())
            ->withPhones([
                new Phone(7, '888', '00000001'),
                new Phone(7, '888', '00000002'),
            ])
            ->build();
 
        $this->repository->add($employee);
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertEquals($employee->getId(), $found->getId());
        $this->assertEquals($employee->getName(), $found->getName());
        $this->assertEquals($employee->getAddress(), $found->getAddress());
 
        $this->assertEquals(
            $employee->getCreateDate()->getTimestamp(),
            $found->getCreateDate()->getTimestamp()
        );
 
        $this->checkPhones($employee->getPhones(), $found->getPhones());
        $this->checkStatuses($employee->getStatuses(), $found->getStatuses());
    }
 
    public function testSave(): void
    {
        $employee = (new EmployeeBuilder())
            ->withPhones([
                new Phone(7, '888', '00000001'),
                new Phone(7, '888', '00000002'),
            ])
            ->build();
 
        $this->repository->add($employee);
 
        $edit = $this->repository->get($employee->getId());
 
        $edit->rename($name = new Name('New', 'Test', 'Name'));
        $edit->addPhone($phone = new Phone(7, '888', '00000003'));
        $edit->archive(new \DateTimeImmutable());
 
        $this->repository->save($edit);
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertTrue($found->isArchived());
        $this->assertEquals($name, $found->getName());
 
        $this->checkPhones($edit->getPhones(), $found->getPhones());
        $this->checkStatuses($edit->getStatuses(), $found->getStatuses());
    }
 
    public function testRemove(): void
    {
        $id = new Id(uniqid());
        $employee = (new EmployeeBuilder())->withId($id)->build();
        $this->repository->add($employee);
 
        $this->repository->remove($employee);
 
        $this->expectException(NotFoundException::class);
 
        $this->repository->get($id);
    }
 
    private function checkPhones(array $expected, array $actual): void
    {
        $phoneData = static function (Phone $phone) {
            return $phone->getFull();
        };
 
        $this->assertEquals(
            array_map($phoneData, $expected),
            array_map($phoneData, $actual)
        );
    }
 
    private function checkStatuses(array $expected, array $actual): void
    {
        $statusData = static function (Status $status) {
            return [
                'value' => $status->getValue(),
                'date' => $status->getDate()->getTimestamp(),
            ];
        };
 
        $this->assertEquals(
            array_map($statusData, $expected),
            array_map($statusData, $actual)
        );
    }
}

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

Теперь для нашего тренировочного MemoryEmployeeRepository напишем тест-наследник, который будет подставлять нужный объект в родительское поле $this->repository:

namespace tests\unit\repositories;
 
use app\repositories\MemoryEmployeeRepository;
 
class MemoryEmployeeRepositoryTest extends BaseRepositoryTest
{
    /**
     * @var \UnitTester
     */
    public $tester;
 
    public function _before(): void
    {
        $this->repository = new MemoryEmployeeRepository();
    }
}

Тесты готовы. Пора приступать к написанию репозиториев:

namespace app\repositories;
 
use app\entities\Employee\Employee;
use app\entities\Employee\Id;
use Ramsey\Uuid\Uuid;
 
class MemoryEmployeeRepository implements EmployeeRepository
{
    private $items = [];
 
    public function get(Id $id): Employee
    {
        if (!isset($this->items[$id->getId()])) {
            throw new NotFoundException('Employee not found.');
        }
        return clone $this->items[$id->getId()];
    }
 
    public function add(Employee $employee): void
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function save(Employee $employee): void
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function remove(Employee $employee): void
    {
        if ($this->items[$employee->getId()->getId()]) {
            unset($this->items[$employee->getId()->getId()]);
        }
    }
 
    public function nextId(): Id
    {
        return new Id(Uuid::uuid4()->toString());
    }
}

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

Запустим наши тесты:

vendor/bin/codecept run unit repositories
Unit Tests (5) ---------------------------------------------
 MemoryEmployeeRepositoryTest: Get (0.01s)
 MemoryEmployeeRepositoryTest: Get not found (0.00s)
 MemoryEmployeeRepositoryTest: Add (0.00s)
 MemoryEmployeeRepositoryTest: Save (0.00s)
 MemoryEmployeeRepositoryTest: Remove (0.00s)
------------------------------------------------------------

Time: 146 ms, Memory: 6.00MB

OK (5 tests, 14 assertions)

Объект ведёт себя корректно.

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

В следующих статьях напишем три реальные реализации EmployeeRepository:

и будем их проверять этими же универсальными тестами.

Комментарии

 

Владимир

А что делать если на такие вещи

array_map(function (PhoneDto $phone) {
    return new Phone(
        $phone->country,
        $phone->code,
        $phone->number
    );
}, $dto->phones)

не хватает памяти, т.к телефонов несколько тысяч. Логично что не нужно загружать их все? А как тогда логика проверки на уникальность и т.д. Или можно будет придумать ленивую загрузку?

Ответить

 

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

По несколько тысяч телефонов у каждого сотрудника?

Ответить

 

Владимир

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

Ответить

 

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

Тогда сим-карты будут сами по себе отдельными сущностями с id и client_id. В отдельной таблице и со своим репозиторием и сервисом. А не внутри клиента.

Ответить

 

antin_z

То есть будете делать по репозиторию на таблицу, верно?

Ответить

 

Виктор

Полагаю, по репозиторию на сущность. Но с телефонами пример хороший, хотел спросит другое: кто\что должен врзвращать, например, количество телефонов у сотрудника.

Ответить

 

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

Если нужно для логики домена, то:

$count = $phoneRepository->countByClient($clientId);
if ($count > 100) {
    throw new \DomainException('Too many phones.);
}

Если же нужно выводить в листинге на сайте, то сделаете JOIN или подзапрос с SELECT COUNT(*) и вернёте в поле во вью-модели ClientView.

Ответить

 

Андрей

Получается если мне надо выбрать , например, всех пользователей у которых количество телефонов скажем больше пяти, то надо создать новый метод в EmployeeRepositoryMysql в котором будет JOIN к таблице с телефонами? Т.е. можно в рамках одного репозитория работать с разными таблицами?

И если пользователи и телефоны хранятся в разных таблицах, как и где осуществлять сборку полноценного пользователя с телефонами?

Ответить

 

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

Да, обращаться можно. Так и в магазине для товаров будет метод вроде getAllByCategory($id, $offset, $limit).

Если собирать нужно для вывода в представлении и оригинальные сущности там использовать неудобно, то для выборок на сайте сделаете отдельный ClientReadRepository с методом getAllWithPhones, который будет возвращать собранный ClientView.

Ответить

 

Патрик Фельдеш
Комментарий удалён
Ответить

 

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

Controller -> EmployeeService -> Employee

Контроллеры дёргают только EmployeeService. Он у нас и является набором юз-кейсов, которые уже оперируют сущностями. К сущностям напрямую контроллер никакого доступа не имеет.

Ответить

 

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

Спасибо. С удовольствием провел пол часа своей жизни за чтением вашей статьи.
С нетерпением жду продолжения!

Ответить

 

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

Так же пара вопросов:

1. В Dto объектах разрешается делать поля публичными? Не является ли это нарушением инкапсуляции?

2. Если у сервиса есть несколько методов, например, описанный вами UserService. В каких-то методах нужен passwordHasher, в каких-то нет. Получается что для того, что вызвать метод changeEmail нам все равно нужно передать в конструктор объект passwordHasher, который в данном случае нам не нужен. Что обычно делается в таких случаях? Сервис разделяется на несколько частей? Или используется как есть?

Ответить

 

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

1. DTO - это структура данных (замена ассоциативного массива), а не полноценный объект.

2. В подходе CQRS с Command Bus на каждую команду (DTO) пишется по одному классу-обработчику. Например, для команды ProductCreateCommand делается ProductCreateHandler и из контроллера все команды закидываются в шину как здесь и здесь. Получается больше кода, но лишние объекты не иньектятся. Поэтому либо разделяют на несколько частей, либо не беспокоятся за лишние полкилобайта памяти для PasswordHasher.

Ответить

 

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

Круто.

А куда можно добавить транзакцию? Может в контроллере что то подобное?

try {
    $this->conn->beginTransaction();
    $this->service->create($data);
    $this->conn->commit();
} catch (\Exception $e) {
    $this->conn->rollback();
}
Ответить

 

antin_z

В сервисе и только в сервисе

Ответить

 

Александр

Может стоит в класс Id добавить метод

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

а-то записи вида $employee->getId()->getId() выглядят не очень.

Ответить

 

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

Можно.

Ответить

 

Добрый сосед

Чем отличаются методы у репозитория add и save?

Ответить

 

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

Метод add производит INSERT-запрос, а save выполняет UPDATE.

Ответить

 

Glagola

У меня в репозиториях только один метод-save, отписался тут на данную тему.

Ответить

 

Denis Klimenko

Спасибо за статью)
можно сделать так чтобы DTO-шки наследовались от одной - чтобы руками не сетить свойства

class BaseDTO
{
    public function __construct(...$params)
    {
        $reflect = new ReflectionClass($this);
        $props  = $reflect->getProperties(ReflectionProperty::IS_PUBLIC);

        foreach ($props as $i => $prop) {
            $property = $prop->getName();
            $this->$property = $params[$i];
        }
    }
}

class UserExampleDTO extends BaseDTO
{
    public $firstname;
    public $lastname;
    public $middlename;
}


$dto = new UserExampleDTO('Bob', 'Lavrov', 'Oleksandrovich');

echo $dto->firstname . PHP_EOL;
echo $dto->lastname . PHP_EOL;
echo $dto->middlename . PHP_EOL;

И по поводу именования методов репозитория, хорошо бы иметь некий стандарт, - если знаете такие делитесь)

Spring - Table 4. Supported keywords inside method names

Ответить

 

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

Надёжнее засеттить руками, через гидратор:

$dto = $this->hydrator->hydrate(new UserExampleDTO(), [
    'firstname' => 'Bob',
    'lastname' => 'Lavrov',
    'middlename' => 'Oleksandrovich'
]);

или аналогично через Yii::configure($dto, [...]).

Иначе в десяти свойствах поменяете что-то местами и весь проект накроется.

Ответить

 

Denis Klimenko

и в итоге лучше сетить руками, через свойства объекта :D
спасибо)

Ответить

 

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

А по именованию add/persist, remove/delete... Кому как повезёт :)

Ответить

 

Alex

Наверное стоит вынести логику валидации из сервисов в отдельный какой-то модуль, а лучше для этого использовать готовую библиотеку, например https://github.com/Respect/Validation

Ответить

 

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

Простая валидация ввода производится в формах. А в сервисах и в сущностях производим проверку бизнес-требований.

Ответить

 

Alex

Вот у вас в UserService два метода, начинающиеся на guard. Это бизнес валидация: сервисов может быть много, валидаций еще больше, предлагаете описывать вручную все эти методы?

Почему, например, слово guard? Другой разработчик новый метод назовет по-другому, в итоге получим беспорядок в конкретном сервисе, не говоря о том, что эти методы могут дублироваться из сервиса в сервис.

Ответить

 

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

Не нравится слово guard - назовите с assert или check.

А как Вы будете "не вручную" для проверки уникальности свою библиотеку использовать? Приведите пример кода, если не сложно.

Ответить

 

Alex

Про конкретное слово я упомянул, чтобы указать на некий общий паттерн, проблему которого решает тот же Yii2, например или библиотека выше(и даже не одна) Почему бы не сделать также? Задавать в бизнес-моделях только правила валидации, а вызывать валидацию в сервисе. Вот тут можно посмотреть на пример того, что я предлагаю http://validatejs.org/ (раздел Constraints, библиотека под js).

И да, речь идет только о бизнес валидации, не касаясь валидации конкретных форм.

Ответить

 

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

Напишите пример своего UserService со своими проверками вместо моих guard-ов. Сравним.

Ответить

 

Alex

Зачем? Вам непонятна моя идея? Я постарался объяснить, привел пример, вы не знаете js?

Ответить

 

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

> Зачем?

Так принято вести конструктивную аргументированную дискуссию.

> привел пример

Нет, пример Вы так и не привели.

Ответить

 

Alex

Я считаю по-другому и, так как, текущее обсуждение отошло от изначальной проблематики я не вижу смысла его продолжать. Спасибо за уделенное время!

Ответить

 

Denis

Дмитрий, подскажите, что означает "Простая валидация ввода"? Как приблизительно понять границу, когда валидация уже перейдет в бизнес-требование?

Например, "поле должно быть числом больше 0" - это бизнес-правило или простая валидация формы?

Ответить

 

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

Представьте, что валидации в форме у Вас нет. Тогда сразу становится видно, что на правило "поле должно быть числом" - это просто формат ввода, на который можно не обращать внимания и положиться на типизацию. А вот "сумма платежа должно быть больше 0" и "не больше баланса на счету" - это уже опасные бизнес-правила, которые желательно в ядре проверить и Exception кинуть.

Ответить

 

Roman

Дмитрий, а как понять где мне нужно писать проверку бизнес требований, в сущности или в сервисе?

Ответить

 

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

Если все нужные для проверки данные находятся в сущности или VO, то и проверяем как можно глубже в сущности или VO. Если же внутри что-то проверить не удаётся, то придётся выносить наружу где все данные есть.

Ответить

 

Vladimir

Проверку прав, ролей в сервисе лучше реализовывать?

Ответить

 

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

Обычно проверяем в контроллере:

if (!Yii::$app->user->can('editPost', ['post' => $post]) {
    throw new ForbiddenHttpException();
}

Потому как в обычном контроллере такая проверка нужна, а в консольном - нет.

А если система доступа весьма продвинутая, то делаем её отдельным сервисом и из класса-правила EditOwnPostRule дёргаем его allowToEditPost($userId, ...).

Ответить

 

Добрый сосед

Если у EmployeeCreateDto EmployeeCreateForm не отличаются поля, можно использовать EmployeeCreateForm как DTO? А если надо преобразование какое, то сделать это в сервисе.

Ответить

 

Добрый сосед

Т.е. не вместо. Можно отказаться от DTO и передавать в сервис сами поля из формы? Для чего DTO необходим вообще?

Ответить

 

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

Про это говорил в статье.

Ответить

 

Добрый сосед

Если мы откажемся от DTO в пользу форм фреймворка, то у нас уже будет не совсем DDD. А использовать DTO мы будем только для промежуточного хранения данных? Получится что то на пободобие того, о чем тут говорят.

Ответить

 

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

Нет. DDD - это философия программирования терминами реальной жизни. Если перейдём на формы, то потеряем только фреймворконезависимость.

Ответить

 

Добрый сосед

Спасибо. А если нам нужно будет изменять уже созданного Employee целиком в одной форме, то кто должен создавать EmployeeUpdateForm. В сущности Employee делать метод getEmployeeUpdateForm()? Или создавать отдельный сервис, который бы возвращал нужную форм-модель?

Отдельно спасибо за отредактированные ссылки:)

Ответить

 

Дмитрий Елисеев
$form = new EmployeeUpdateForm($employee);
Ответить

 

Евгений – catine.ru

Отличная статья! Как раз для любителей тонких контроллеров и тонких методов.

Ответить

 

anton_z

А почему не стали использовать синглтон для диспетчера событий и не публикуете их прямо из агрегата?
Я знаю, что синглтон многие не любят за неявность, мне интересно, почему именно такая реализация была выбрана.

Ответить

 

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

Из-за проблем с транзакциями, про которые говорил в прошлой статье.

Ответить

 

anton_z

Я в коде к книге DDD in PHP такое решение нашел: там целиком прикладные сервисы оборачиваются в транзакции. Внутри сервис о транзакции ничего не знает, однако, все что происходит в сервисе, должно быть транзакционно. События, которые влияют на внешний нетранзакционный мир обозначаются специальным интерфейсом PublisableEvent, обработчик этих событий сохраняет их в базу под той же транзакцией. Потом другой процесс обрабатывает события.

Ответить

 

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

Да, такое оборачивание легко реализуется с CommandBus. А синглтоны не люблю в тестах.

Ответить

 

anton_z

А шина может/должна ответ возвращать (лучше типизированный)? У меня по вариантам использования разделение на команды/запросы не везде получается - нужны возвраты.

Ответить

 

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

В асинхронной шине не должна.

Ответить

 

Серж

Скажите, а как же быть со связями в репозиториях? Как мне получить у user допустим его profile ? Хотелось бы увидеть в статье эту тему.

Ответить

 

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

Внутри user будет $this->profile. В следующих статьях есть реализация ленивой загрузки.

Ответить

 

Ivan

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

Ответить

 

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

В событие передаём старый адрес и новый:

new AddressChanged($oldAddress, $newAddress);

и в обработчике сравниваем, изменился город или нет.

Ответить

 

Denis Pugachev

Дмитрий, у меня возник следующий вопрос.
Допустим ситуация: я реализую API метод на создание объекта, который должен вернуть ответ на запрос с полями объекта.

Я вызываю сервис создания объекта, передавая ему параметры.
Какой из следующих вариант с точки зрения DDD будет наиболее верным?
1. В ответ сервис возвращает мне созданный объект. В контроллере я из этого объекта извлекаю поля и возвращаю в виде JSON
2. В ответ сервис возвращает мне ID созданного объекта. Из другого сервиса в контроллере я получаю объект по ID и возвращаю его в виде JSON
3. В ответ сервис мне отдает ViewModel или DTO, в котором реализован например метод toArray, и я прямиком его отдаю в json_encode

Если у вас есть другой вариант, пожалуйста, опишите его.
Спасибо!

Ответить

 

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

Универсальнее вариант 2. Но можно и 1, если нет отдельной ReadModel.

Ответить

 

Сергей

Все предельно ясно, спасибо. Один вопрос, хотелось бы увидеть как именно билдится $employeeService с его зависимостями и как происходит injection в контроллер.
Ведь обычно в приложении не делается new SomeController - фреймворк инстанциирует его за нас. Или я что-то не понимаю)

Ответить

 

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

Фреймворк делает Yii::createObject('app\controllers\SomeController', [$id, $module]) через контейнер, а уже он рекурсивно парсит конструкторы классов, подставляя недостающий Yii::createObject('app\services\EmployeeService') и так далее.

Ответить

 

Антон Пресняков – github.com

Здравствуйте! Спасибо за статью! Очень интересно. У меня вопрос про uuid. Несколько непривычно его использовать, скажите есть ли недостатки такого подхода?

Ответить

 

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

Недостаток случайного UUID - не получится по привычке делать ORDER BY id. Проблема решается через ORDER BY create_date.

Ответить

 

Сергей Доровский

Дмитрий, как бы ты реализовал аутентификацию и авторизацию для Employee в рамках yii?

Ответить

 

Дмитрий Елисеев
class AuthController extends Controller
{
    ...

    public function actionLogin()
    {
        if (!Yii::$app->user->isGuest) {
            return $this->goHome();
        }

        $form = new LoginForm();
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            try {
                $user = $this->authService->auth($form);
                Yii::$app->user->login(new Identity($user), $form->rememberMe ? $this->rememberMeDuration : 0);
                return $this->goBack();
            } catch (\DomainException $e) {
                Yii::$app->errorHandler->logException($e);
                Yii::$app->session->setFlash('error', $e->getMessage());
            }
        }

        return $this->render('login', [
            'model' => $form,
        ]);
    }
}
class Identity implements IdentityInterface
{
    private $user;

    public function __construct(User $user) {
        $this->user = $user;
    }

    public static function findIdentity($id): ?self {
        $user = self::getRepository()->findActiveById($id);
        return $user ? new self($user) : null;
    }

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

    public function getAuthKey(): string {
        return $this->user->auth_key;
    }

    public function validateAuthKey($authKey): bool {
        return $this->getAuthKey() === $authKey;
    }

    private static function getRepository(): UserReadRepository {
        return \Yii::$container->get(UserReadRepository::class);
    }
}
Ответить

 

Сергей

Здравствуйте. В контроллере вы проверяете исключения DomainException, а в репозитории выбрасываете RuntimeException, которые получается нигде не обрабатываются? Их тоже следует обрабатывать в контроллере получается?

Ответить

 

Сергей Доровский

Был похожий вопрос. RuntimeException это ни что иное, как 500 или 503 ошибка.

Ответить

 

Сергей

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

Ответить

 

Сергей Доровский

При неудачном сохранении не должно быть RuntimeException. Необработанный RuntimeException сигнализирует о том, что приложение нужно завершить, т.к. дальнейшее выполнение невозможно. Если тебе кажется, что ты можешь его обработать и продолжить работу, значит неправильный Exception бросаешь. Имхо, конечно.

Ответить

 

Евгений

Спасибо за хорошую статью. У меня есть два вопроса:

1. В примере достаточно простая задача реализуется, поэтому сервисный уровень не большой умещенный в один класс. Он по сути повторяет интерфейс модели. В реальности модель сложней, а значит и сервисный уровень так же. Допустимо ли разделение сервиса обслуживающего определенный объект (группу объектов) на части? Например вынос создания нового сложного объекта в отдельный класс? Иначе сервисный класс становится слишком большим и имеет слишком много ответственности.

2. Мне не понятно где нужно выполнять проверку значений объекта модели. В вопросах я видел что Вы рекомендуете типизацию проверять в объекте, а требования бизнеса уже в формах. Но в документации по DDD много где встречается, что объекты модели должны соблюдать свои инварианты, а это в моем понимании намного больше чем проверить что значение действительно является числом. В качестве примера: номер телефона абонента обязательно должен быть из штата Техас для одного тарифного плана услуги, а для другого допускается вся страна.

а. мы должны проверить что значение должно быть телефоном
б. мы должны проверить номер телефона в соответствии с тарифным планом.

По идее проверка 2 не является основной для предоставления услуги, а значит не является частью модели, но с другой стороны ее отсутствие в модели нарушает инварианты.

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

Спасибо за ответы!

Ответить

 

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

1. Всё допустимо. Если чего-то стало много, то выносим. Для создания сложных объектов как раз и придуманы фабрики и построители.

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

Наборот. В формах ставим примитивные правила валидации на формат: число, строку, email, телефон и для красоты required. А в объектах уже проверяем на Техас и другие сложные бизнес-требования.

3. Формы сами умеют выводить свои ошибки валидации. А из модели уже вылетают DomainException, которые выводим во flash-сообщении.

Ответить

 

Евгений

Спасибо за ответ.

А формы относятся к какому уровню приложения? И каким образом они кидают ошибки. Поясню суть проблемы:

Приложение имеет несколько языков. Во время инициализации создается объект с нужными языковыми константами.

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

С другой стороны, они все одинаковые, и различия между ними нет, их задача вернуть описание ошибки. Т.е. Достаточно сделать throw new UserException($this->constants->msg1)

А вид в таком случае просто отобразит $e->getMessage()

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

Что скажете?

Ответить

 

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

Формы создаются в контроллере и рендерятся в HTML-коде. Значит вместе с контроллерами относятся к слою UI. Они выводят ошибки валидации.

Ответить

 

Roman

Дмитрий, человек ниже задал хороший вопрос - но он так и остался без ответа:

что, если ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении? Как быть в этом случае?

Можете ли привести хотя бы минимальный пример решения ?

Ответить

 

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

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

Ответить

 

Eugeniy Urvantsev

Приветствую

>3. Формы сами умеют выводить свои ошибки валидации. А из модели уже вылетают DomainException, которые выводим во flash-сообщении.

что, если такие ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении?
как это делают стандартные валидаторы

Ответить

 

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

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

Ответить

 

Eugeniy Urvantsev

Это я знаю
Но вы пишите domain exception для проверки уникальности данных, который возникает в сервисе. К тому же, я считаю, что использование валидаторов в форме, которые ходят в базу - плохой практикой.

Ответить

 

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

Тогда сделайте отдельный класс UserExistsException extends DomainException, поймайте через catch и привяжите ошибку к полю уже в контроллере или шаблоне.

Ответить

 

Ом

Здравствуйте. Подскажите пожалуйста, где инициализируются Сервисы. Я вижу, что они передаются контроллеру в конструтор, а где создается контроллер не могу найти.

Ответить

 

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

Ответил выше на комментарий Сергея от 29 сентября.

Ответить

 

Евгений

Тут, кажется, опечатка. Вместо AddressDto должно быть EmployeeReinstateDto.

class ReinstateForm extends Model
{
    public $year;
    public $mounth;
    public $day;
 
    public function rules(): array { ... }
 
    public function getDto(): <b>AddressDto</b>
    {
        $dto = new <b>AddressDto();</b>
        $dto->date = DateTimeImmutable::createFromFormat('Y-m-d', $this->year . '-' . $this->mounth . '-' . $this->day);
        return $dto;
    }
}
Ответить

 

Egor Ushakov

А куда принято "складывать" классы своих сервисов в Yii2 Advanced Template? Я понимаю, что можно в любую директорию и добавить autoload в composer.json. Но может уже какая устоявшаяся best practice по этому поводу есть? Просто первый раз сталкиваюсь с Yii2. Сходу в документации не нашел. А в проекте, как обычно, уже все вчера должно было быть.

Ответить

 

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

Никакой устоявшейся нет.

Ответить

 

Alexey Verkhovtsev

Здравствуйте! Спасибо за статью!
Несколько вопросов

1) Может ли 1 сервис инжектить другой?

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

3) На самом деле переплетается с первыми двумя. Есть 2 сервиса и каждый имеет по методу для создания, 1 создает шары, другой людей. Задача после создания человека создавать шар и ему передавать человека. Это реализовывать в методе create человека сразу после его создания или же вернуть созданный объект и в контроллере вызвать 2 метод из другого сервиса и передать туда его?

Заранее спасибо

Ответить

 

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

1) Да, может.

2) Циклические зависимости нужны только в крайнем случае. Обычно можно обойтись без них, перепроектировав систему по предложенным вариантам. Но если без них никак не получается, то жадная загрузка спасает.

3) Из контроллера вызвать один сервис, который вызовет по очереди два других. Или скопипастить в него создание обоих, если оно простое.

Ответить

 

Alexey Verkhovtsev

спасибо! По поводу 3, получается не желательно работать с разными сервисами в контроллере в пределах 1 метода?

Ответить

 

Zhukov Sergei

Дмитрий, а где мы создаем заполняем AddressDto? И вообще DTO в целом? Спасибо за статью.

Ответить

 

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

В контроллере.

Ответить

 

Сергей

Очень полезно, спасибо. Только мне кажется, что если добавлять телефон (дополнительный), то не происходит проверки, что он уже есть. В Сущности Employee добавляется сразу add(Phone $phone)

Ответить

 

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

Эта проверка у нас находится внутри add.

Ответить

 

Dmitrii Shitikov

Здравствуйте,

В статье DTO делается с открытыми свойствами:

class AddressDto
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
}

Нет опасности, что свойства могут перезаписать по пути? Что если сделать их закрытыми и добавить геттеры?

Ответить

 

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

Если нужна гарантия неизменяемости, то да, можно сделать геттеры.

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

/**
 * @psalm-immutable
 */
class AddressDto
{
    public $country;
}

Либо скоро дождаться PHP 8.1, где поля можно будет помечать как readonly.

Ответить

 

Овчинников Артём

Отличный материал спасибо. Я видел подобное в NestJs entity services dto теперь немного начал понимать вроде После полного прохождения недели ООП я вернусь снова к этой статье пока рано(

Ответить

 

Макс

Ох и школота начальная.

Ответить

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

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


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





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