Проходя мимо Action классов в Laravel

Источник: «Going past Actions in Laravel»
За последний год подход, основанный на action классах, набирает популярность в мире Laravel. Я принял его довольно рано и был большим поклонником этого подхода.

Однако со временем я обнаружил, что помещаю всё мыслимое пространство в пространство имён Action классов, и каталог становиться всё более переполненным. Я перешёл от попыток упростить свой процесс к извлечению всего в Action классы — и найти нужное с каждым разом становиться всё сложнее.

Я решил найти способ, который позволил бы по-прежнему получать пользу от этого подхода, но без того, чтобы всё было классифицировано как экшен. Ранее использовав различные архитектурные шаблоны, и вспомнил некоторые из них, которые хорошо работали. Я был большим поклонником CQRS (Разделение Ответственности за Запросы Команд). Однако мне не нравился ручной подход к добавлению всех моих сопоставлений, я хотел бы полагаться на Командную Шину (Command Bus) или Шину Запросов (Query Bus). Но я мог взять то, что мне нравилось в этом шаблоне и использовать Laravel контейнер, чтобы делать то, что нужно.

Поэтому я решил взять то, что я классифицировал как Action классы, и разделил их на что-то более близкое к подходу CQRS. Команды — это действия записи, которые я хочу выполнить, а запросы — действия чтения. Давайте рассмотрим пример этих изменений.

namespace App\Actions;

final class CreateNewUserAction
{
public function handle(NewUser $user): Model|User
{
return DB::transaction(
callback: static fn () => User::query()->create($user->toArray()),
attempts: 2,
);
}
}

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

namespace App\Commands\Users;

final class CreateNewUser
{
public function handle(NewUser $user): Model|User
{
return DB::transaction(
callback: static fn () => User::query()->create($user->toArray()),
attempts: 2,
);
}
}

Это то же самое, но в другом пространстве имён (namespace), я получил лучшее разделение своём коде. Вы можете аналогичным способом подходить к Action классам и создавать вложенные пространства имён (namespace), но вы не получите такого же разделения намерений, как при разделении операций чтения и записи. Давайте рассмотрим более сложный пример.

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

Это много, если рассматривать это в контроллере, и если вы используете Action классы и даже команды и запросы — в конечном итоге вы соберёте много вещей в одном месте, именование станет беспорядочным, и вы не получите большой выгоды. Вместо этого я использую другой подход и выстраиваю процесс. Многое из того, что мы делаем в наших приложениях, — это процессы, последовательный список действий, которые необходимо выполнить для достижения результата. Это ничем не отличается. Во-первых, посмотрите на базовый код — абстрактный процесс, который мы хотим реализовать.

abstract class Process
{
protected array $tasks = [];

public function run(object $payload): mixed
{
return Pipeline::send(
passable: $payload,
)->through(
pipes: $this->tasks,
)->thenReturn();
}
}

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

Как это относится к командам и запросам? Давайте углубимся в пример.

final class TeamCreationProcess extends Process
{
protected array $tasks = [
CreateNewTeam::class,
AssignNewTeamMember::class,
NotifyTeamOwnerOfNewMember::class,
SetupBillingForTeam::class,
];
}

Всё, что нужно сделать в контроллере, это:

final class StoreController
{
public function __construct(
private readonly TeamCreationProcess $process,
) {}

public function __invoke(StoreRequest $request): Responsable
{
$this->process->run($request->payload());

return new MessageResponse(
message: 'Your team has been created',
);
}
}

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

final class CreateNewTeam
{
public function __construct(
private readonly NewTeamCreation $command,
) {}

public function __invoke(object $payload, Closure $next): mixed
{
$this->command->handle($payload);

return $next($payload);
}
}

Наши задачи могут вызывать наши команды вместо реализации той же логики. Вы можете добавить сюда действие записи. Однако по-прежнему используя команду, вы легко можете создать новую команду из API, Web или CLI не встраивая её в процесс. Если у вас была простая команда CLI, вы, скорее всего, хотите избежать процесса — вам нужно быстрое действие.

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

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

Дополнительные материалы

Предыдущая Статья

Как получить вошедшего в систему пользователя

Следующая Статья

Новое в Symfony 6.3 — Компонент Scheduler