Минималистичная схема Action и Use Case в PHP: общий сценарий приложения разделяет логику на сервисы, работу с базой данных, доменные правила и внешние операции.

Actions и Use Cases в PHP: куда выносить повторяющуюся логику

В PHP-проектах часто используют классы под конкретные сценарии приложения. Где-то их называют Action, где-то UseCase, где-то ApplicationService, где-то CommandHandler.

Названия разные, но идея одна: отдельный класс описывает конкретное действие системы.

Например:

  • создать бронь;
  • оплатить заказ;
  • отменить заявку;
  • создать счёт;
  • импортировать товары;
  • опубликовать статью;
  • изменить даты проживания;
  • отправить заявку во внешнюю систему.

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

Но потом появляется типичная ситуация:

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

И возникает вопрос:

Куда выносить повторяющуюся логику между Actions?

Короткий ответ:

Если общий код не является самостоятельным сценарием приложения, его не стоит превращать в новый Action. Место для такой логики определяется её смыслом: доменное правило, шаг сценария, запрос к базе, mapper, repository, infrastructure service, event или job.

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

Содержание
  1. Коротко
  2. Что такое Action / Use Case
  3. Action — не контейнер для всего кода
  4. Почему общий код не всегда должен становиться новым Action
  5. Главное правило выбора места
  6. Если это бизнес-правило — ближе к домену
  7. Если это общий шаг сценария — Application Service
  8. Если это работа с базой данных — Repository или Query Service
  9. Repository
  10. Query Service
  11. Persistence Service
  12. Что делать с транзакциями
  13. Можно ли вызывать Action из Action
  14. Пример плохой структуры
  15. Пример нормальной структуры
  16. Как называть такие сервисы
  17. Если общий кусок — подготовка данных
  18. Если общий кусок — побочный эффект
  19. Если общий кусок — техническая операция
  20. Если общий кусок — проверка прав
  21. Антипаттерны
  22. 1. Делать Action на каждый общий кусок
  23. 2. Вызывать Action из Action без понимания транзакций
  24. 3. Складывать всё вSomeService
  25. 4. Использовать Helper / Utils
  26. 5. Прятать бизнес-правила в Repository
  27. 6. Открывать транзакции внутри вложенных сервисов
  28. 7. Смешивать чтение и доменное поведение
  29. 8. Делать общий сервис слишком абстрактным
  30. Практическая карта решений
  31. Как понять, что структура нормальная
  32. Итог

Коротко

Если между Actions повторяется код, ориентируйтесь на смысл этого кода.

Повторяющаяся логикаКуда выносить
Бизнес-правилоEntity / Aggregate / Domain Service
Общий шаг сценарияApplication Service
Чтение из базы данныхQuery Service
Загрузка или сохранение моделиRepository
Сложное сохранение в несколько таблицPersistence Service
Подготовка DTOMapper / Factory
Нормализация входных данныхNormalizer
Внешний APIClient / Gateway / Adapter
УведомленияEvent / Listener / Queue Job
Публикация событияEvent / Listener / Job
Проверка правPolicy / Authorization Service
ТранзакцияВнешний Action / Transaction Manager

Главная мысль:

Action или Use Case должен описывать сценарий приложения. Общий технический или внутренний шаг сценария не обязан становиться новым Action.

Что такое Action / Use Case

Action или Use Case — это класс, который описывает конкретный сценарий приложения.

В разных проектах его могут называть по-разному:

  • Action;
  • UseCase;
  • ApplicationService;
  • CommandHandler;
  • Interactor;
  • Scenario.

Смысл похожий: такой класс координирует выполнение конкретного действия.

Примеры:

  • CreateBookingAction;
  • ChangeBookingDatesUseCase;
  • CancelBookingAction;
  • PayOrderUseCase;
  • CreateInvoiceAction;
  • ImportProductsUseCase;
  • PublishArticleAction.

Обычно Action находится в application layer. Он собирает сценарий из нескольких частей:

  • принимает входные данные;
  • открывает транзакцию;
  • загружает нужные модели;
  • вызывает доменные методы;
  • обращается к репозиториям;
  • вызывает сервисы;
  • создаёт события;
  • запускает фоновые задачи;
  • возвращает результат.

Пример:

class CreateBookingAction
{
    public function __construct(
        private readonly TransactionManager $transactionManager,
        private readonly RoomAvailabilityService $roomAvailabilityService,
        private readonly BookingRepository $bookingRepository,
    ) {
    }

    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingData->roomId,
                    period: $bookingData->period,
                );

                $booking = Booking::create(
                    roomId: $room->id,
                    guestId: $bookingData->guestId,
                    period: $bookingData->period,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Этот Action отвечает за сценарий: создать бронь.

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

Action — не контейнер для всего кода

Самая частая ошибка — начать складывать в Action всю логику сценария.

Получается класс на 300 строк:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        // получить комнату
        // проверить доступность
        // проверить тариф
        // пересчитать цену
        // проверить гостя
        // создать бронь
        // сохранить гостей
        // записать audit log
        // отправить уведомление
        // синхронизировать с PMS
        // отправить webhook
        // обновить статистику
    }
}

Формально контроллер стал тонким. Фактически сложность переехала в новый класс.

Это не архитектура. Это переезд свалки из контроллера в Action. Коробки подписали красиво, но жить там всё равно тяжело.

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

Почему общий код не всегда должен становиться новым Action

Допустим, есть два сценария:

  • создать бронь;
  • изменить даты брони.

Оба сценария проверяют доступность номера на период.

Первый соблазн — сделать общий Action:

class CheckRoomAvailabilityAction
{
}

Но проверка доступности номера сама по себе может быть не сценарием приложения. Пользователь не запускает отдельный use case “проверить номер” внутри бизнес-процесса создания брони. Это внутренний шаг.

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

  • CommonBookingAction;
  • PrepareSomethingAction;
  • DoSharedPartAction;
  • HandleReusableLogicAction;
  • ProcessDatabaseStuffAction;
  • SharedFlowAction.

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

Action должен называться как действие приложения:

  • CreateBookingAction;
  • CancelBookingAction;
  • PayOrderAction;
  • PublishArticleAction.

Если название звучит как технический helper, это, скорее всего, не Action.

Главное правило выбора места

Место для общей логики определяется смыслом.

Задайте вопрос:

Что делает повторяющийся код?

Варианты:

  • защищает бизнес-правило;
  • выполняет общий шаг сценария;
  • читает данные из базы;
  • сохраняет агрегат или модель;
  • готовит DTO;
  • нормализует данные;
  • вызывает внешний API;
  • отправляет уведомление;
  • публикует событие;
  • проверяет права;
  • управляет транзакцией.

После ответа место обычно становится очевиднее.

Если это бизнес-правило — ближе к домену

Если повторяющаяся логика защищает правило предметной области, ей место ближе к домену:

  • Entity;
  • Aggregate;
  • Value Object;
  • Domain Service.

Примеры бизнес-правил:

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

Пример в доменной модели:

class Booking
{
    public function confirm(): void
    {
        if ($this->status->isConfirmed()) {
            throw new BookingAlreadyConfirmedException();
        }

        $this->status = BookingStatus::confirmed();
    }
}

Если правило относится к самой сущности бронирования, оно должно жить в Booking.

Другой пример — расчёт цены. Если расчёт зависит от доменных правил, его можно вынести в domain service:

class BookingPriceCalculator
{
    public function calculate(
        BookingPeriod $period,
        RoomRate $roomRate,
        GuestCount $guestCount,
    ): Money {
        $nights = $period->nights();

        return $roomRate->pricePerNight->multiply(
            multiplier: $nights * $guestCount->value,
        );
    }
}

Такой сервис выражает доменное правило, а не технический шаг.

Принцип:

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

Если это общий шаг сценария — Application Service

Это самый частый ответ на исходный вопрос.

Есть два Actions:

  • CreateBookingAction;
  • ChangeBookingDatesAction.

В обоих есть общий шаг:

  • получить номер;
  • заблокировать его для изменения;
  • проверить доступность;
  • вернуть номер для дальнейшего сценария.

Этот шаг не является самостоятельным use case. Но он является частью application flow.

Тогда его можно вынести в application service:

class RoomAvailabilityService
{
    public function __construct(
        private readonly RoomRepository $roomRepository,
    ) {
    }

    public function getAvailableRoom(
        RoomId $roomId,
        BookingPeriod $period,
    ): Room {
        $room = $this->roomRepository->getForUpdate(
            roomId: $roomId,
        );

        if (! $room->isAvailableFor(period: $period)) {
            throw new RoomIsNotAvailableException();
        }

        return $room;
    }
}

Теперь оба Actions используют этот сервис:

class CreateBookingAction
{
    public function __construct(
        private readonly TransactionManager $transactionManager,
        private readonly RoomAvailabilityService $roomAvailabilityService,
        private readonly BookingRepository $bookingRepository,
    ) {
    }

    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingData->roomId,
                    period: $bookingData->period,
                );

                $booking = Booking::create(
                    roomId: $room->id,
                    guestId: $bookingData->guestId,
                    period: $bookingData->period,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

И второй сценарий:

class ChangeBookingDatesAction
{
    public function __construct(
        private readonly TransactionManager $transactionManager,
        private readonly RoomAvailabilityService $roomAvailabilityService,
        private readonly BookingRepository $bookingRepository,
    ) {
    }

    public function handle(ChangeBookingDatesData $bookingDatesData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingDatesData): Booking {
                $booking = $this->bookingRepository->getForUpdate(
                    bookingId: $bookingDatesData->bookingId,
                );

                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingDatesData->roomId,
                    period: $bookingDatesData->newPeriod,
                );

                $booking->changePeriod(
                    roomId: $room->id,
                    period: $bookingDatesData->newPeriod,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Что получилось:

  • CreateBookingAction остаётся сценарием создания брони;
  • ChangeBookingDatesAction остаётся сценарием изменения дат;
  • общий шаг вынесен в application service;
  • транзакцией владеет внешний Action;
  • сервис доступности не притворяется отдельным use case.

Если это работа с базой данных — Repository или Query Service

Если повторяющаяся логика в основном работает с базой, её не стоит держать прямо в Actions.

Возможные варианты:

  • Repository;
  • Query Service;
  • Persistence Service.

Repository

Repository хорошо подходит, когда вы работаете с доменной моделью или агрегатом.

Пример:

interface BookingRepository
{
    public function getForUpdate(BookingId $bookingId): Booking;

    public function save(Booking $booking): void;
}

Или:

interface RoomRepository
{
    public function getForUpdate(RoomId $roomId): Room;
}

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

Query Service

Query Service подходит для чтения данных.

Например, нужно получить список доступных номеров:

class AvailableRoomsQuery
{
    public function getAvailableRooms(BookingPeriod $period): array
    {
        // read-only запрос к базе
    }
}

Это не доменное поведение. Это запрос данных.

Query Service уместен для:

  • списков;
  • фильтров;
  • отчётов;
  • read-only данных;
  • сложных выборок;
  • дашбордов;
  • поиска.

Persistence Service

Иногда сохранение технически сложное:

  • сохранить основную модель;
  • сохранить связанные строки;
  • обновить pivot-таблицы;
  • записать историю;
  • синхронизировать несколько таблиц;
  • сохранить snapshot.

В таком случае можно использовать persistence service:

class BookingPersistenceService
{
    public function saveWithGuests(
        Booking $booking,
        array $guests,
    ): void {
        // сложное сохранение брони и гостей
    }
}

Здесь важно не спрятать бизнес-логику внутрь persistence service. Он отвечает за сохранение, а не за принятие бизнес-решений.

Что делать с транзакциями

Транзакция — один из самых важных вопросов.

Если у каждого Action своя транзакция, общий сервис обычно не должен открывать свою транзакцию внутри.

Плохой вариант:

class RoomAvailabilityService
{
    public function getAvailableRoom(
        RoomId $roomId,
        BookingPeriod $period,
    ): Room {
        return $this->transactionManager->run(
            callback: function () use ($roomId, $period): Room {
                $room = $this->roomRepository->getForUpdate(
                    roomId: $roomId,
                );

                if (! $room->isAvailableFor(period: $period)) {
                    throw new RoomIsNotAvailableException();
                }

                return $room;
            },
        );
    }
}

Проблемы:

  • непонятно, кто владеет транзакцией;
  • общий сервис становится менее переиспользуемым;
  • появляется риск вложенных транзакций;
  • сценарий теряет контроль над atomic boundary;
  • код сложнее читать.

Лучше:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingData->roomId,
                    period: $bookingData->period,
                );

                // продолжение сценария
            },
        );
    }
}

То есть:

  • Action владеет транзакцией;
  • общий сервис выполняется внутри этой транзакции;
  • граница сценария остаётся понятной.

Правило:

Транзакцией обычно владеет внешний сценарий. Внутренние сервисы выполняют свои шаги внутри уже заданной транзакционной границы.

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

Можно ли вызывать Action из Action

Вызов Action из Action — тревожный сигнал. Не абсолютный запрет, но повод остановиться.

Плохой пример:

class CreateOrderAction
{
    public function handle(CreateOrderData $orderData): Order
    {
        $order = $this->createOrderAction->handle(
            orderData: $orderData,
        );

        $this->createPaymentAction->handle(
            orderId: $order->id,
        );

        $this->sendNotificationAction->handle(
            orderId: $order->id,
        );

        return $order;
    }
}

Здесь несколько проблем:

  • непонятны транзакционные границы;
  • каждый Action может иметь свои побочные эффекты;
  • сложно контролировать rollback;
  • сценарий становится каскадом скрытых сценариев;
  • тестировать поведение тяжелее.

Часто лучше использовать:

  • application service;
  • domain method;
  • repository;
  • event/listener;
  • queue job;
  • process manager.

Вызов Action из Action может быть уместен, если:

  • один публичный сценарий действительно включает другой публичный сценарий;
  • транзакционные границы очевидны;
  • побочные эффекты контролируются;
  • нет скрытого каскада;
  • это осознанная композиция use cases.

Но по умолчанию лучше сначала поискать более точное место для общей логики.

Пример плохой структуры

Допустим, есть два Actions.

Первый создаёт бронь:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $room = $this->roomRepository->getForUpdate(
                    roomId: $bookingData->roomId,
                );

                if (! $room->isAvailableFor(period: $bookingData->period)) {
                    throw new RoomIsNotAvailableException();
                }

                $booking = Booking::create(
                    roomId: $room->id,
                    guestId: $bookingData->guestId,
                    period: $bookingData->period,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Второй меняет даты брони:

class ChangeBookingDatesAction
{
    public function handle(ChangeBookingDatesData $bookingDatesData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingDatesData): Booking {
                $room = $this->roomRepository->getForUpdate(
                    roomId: $bookingDatesData->roomId,
                );

                if (! $room->isAvailableFor(period: $bookingDatesData->newPeriod)) {
                    throw new RoomIsNotAvailableException();
                }

                $booking = $this->bookingRepository->getForUpdate(
                    bookingId: $bookingDatesData->bookingId,
                );

                $booking->changePeriod(
                    roomId: $room->id,
                    period: $bookingDatesData->newPeriod,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Повторяется:

  • получение номера с блокировкой;
  • проверка доступности;
  • выброс исключения;
  • знание о том, как получить room для изменения.

Это ещё не катастрофа. Дублирование иногда дешевле преждевременной абстракции. Но если такой код появляется в третьем сценарии, уже пора выносить.

Пример нормальной структуры

Выносим общий шаг в сервис с понятной ответственностью:

class RoomAvailabilityService
{
    public function __construct(
        private readonly RoomRepository $roomRepository,
    ) {
    }

    public function getAvailableRoom(
        RoomId $roomId,
        BookingPeriod $period,
    ): Room {
        $room = $this->roomRepository->getForUpdate(
            roomId: $roomId,
        );

        if (! $room->isAvailableFor(period: $period)) {
            throw new RoomIsNotAvailableException();
        }

        return $room;
    }
}

Теперь создание брони:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingData->roomId,
                    period: $bookingData->period,
                );

                $booking = Booking::create(
                    roomId: $room->id,
                    guestId: $bookingData->guestId,
                    period: $bookingData->period,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Изменение дат:

class ChangeBookingDatesAction
{
    public function handle(ChangeBookingDatesData $bookingDatesData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingDatesData): Booking {
                $room = $this->roomAvailabilityService->getAvailableRoom(
                    roomId: $bookingDatesData->roomId,
                    period: $bookingDatesData->newPeriod,
                );

                $booking = $this->bookingRepository->getForUpdate(
                    bookingId: $bookingDatesData->bookingId,
                );

                $booking->changePeriod(
                    roomId: $room->id,
                    period: $bookingDatesData->newPeriod,
                );

                $this->bookingRepository->save(
                    booking: $booking,
                );

                return $booking;
            },
        );
    }
}

Стало лучше:

  • оба Actions читаются как сценарии;
  • общий шаг вынесен;
  • сервис имеет понятное имя;
  • транзакция осталась на уровне Action;
  • работа с базой спрятана за repository;
  • доменная проверка доступности осталась в Room.

Как называть такие сервисы

Плохие названия:

  • CommonBookingService;
  • BookingHelper;
  • SharedActionService;
  • BookingUtils;
  • DatabaseService;
  • ProcessService;
  • FlowHelper.

Такие имена ничего не говорят о смысле.

Хорошие названия:

  • RoomAvailabilityService;
  • BookingPriceCalculator;
  • BookingPeriodNormalizer;
  • RoomForBookingResolver;
  • BookingStatusChanger;
  • CustomerBalanceChecker;
  • OrderPaymentPreparer;
  • InvoiceNumberGenerator.

Название должно отвечать на вопрос:

Какую ответственность выполняет этот класс?

А не:

Почему мы его создали?

SharedService сообщает причину создания: код повторялся.
RoomAvailabilityService сообщает ответственность: работа с доступностью номера.

Второе полезнее.

Если общий кусок — подготовка данных

Иногда повторяется не бизнес-правило и не запрос к базе, а подготовка данных:

  • сбор DTO;
  • преобразование массива;
  • нормализация дат;
  • маппинг внешнего payload;
  • преобразование валют;
  • подготовка параметров;
  • очистка строк;
  • сбор объекта периода.

Такой код лучше выносить в:

  • Mapper;
  • Factory;
  • Normalizer;
  • Assembler.

Пример:

class BookingPeriodFactory
{
    public function fromDates(
        string $dateFrom,
        string $dateTo,
    ): BookingPeriod {
        return new BookingPeriod(
            dateFrom: DateValue::fromString(
                value: $dateFrom,
            ),
            dateTo: DateValue::fromString(
                value: $dateTo,
            ),
        );
    }
}

Или mapper:

class CreateBookingDataMapper
{
    public function fromArray(array $data): CreateBookingData
    {
        return new CreateBookingData(
            roomId: new RoomId(
                value: (int) $data['room_id'],
            ),
            guestId: new GuestId(
                value: (int) $data['guest_id'],
            ),
            period: $this->bookingPeriodFactory->fromDates(
                dateFrom: (string) $data['date_from'],
                dateTo: (string) $data['date_to'],
            ),
        );
    }
}

Action не должен заниматься разбором массива, если это повторяется и усложняет сценарий.

Если общий кусок — побочный эффект

Побочные эффекты часто лучше выносить в события и обработчики.

Примеры:

  • отправить уведомление;
  • записать audit log;
  • отправить webhook;
  • синхронизировать данные с внешней системой;
  • опубликовать сообщение в real-time;
  • отправить email;
  • создать задачу для фоновой обработки.

Плохо, когда Action превращается в список побочных эффектов:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        $booking = $this->createBooking($bookingData);

        $this->notificationService->sendBookingCreated($booking);
        $this->auditLogger->bookingCreated($booking);
        $this->webhookSender->sendBookingCreated($booking);
        $this->statisticsService->incrementBookingsCount();

        return $booking;
    }
}

Часть таких действий можно вынести в событие:

class CreateBookingAction
{
    public function handle(CreateBookingData $bookingData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingData): Booking {
                $booking = $this->createBooking(
                    bookingData: $bookingData,
                );

                $this->eventDispatcher->dispatch(
                    event: new BookingCreated(
                        bookingId: $booking->id,
                    ),
                );

                return $booking;
            },
        );
    }
}

Обработчик:

class SendBookingCreatedNotificationListener
{
    public function handle(BookingCreated $event): void
    {
        $this->notificationService->sendBookingCreated(
            bookingId: $event->bookingId,
        );
    }
}

Если действие долгое или нестабильное, его лучше отправить в очередь:

class SendBookingCreatedWebhookJob
{
    public function handle(): void
    {
        $this->webhookSender->sendBookingCreated(
            bookingId: $this->bookingId,
        );
    }
}

Так основной сценарий не разрастается побочными эффектами.

Если общий кусок — техническая операция

Технические операции лучше держать в инфраструктурном слое.

Примеры:

  • генерация PDF;
  • отправка HTTP-запроса;
  • загрузка файла;
  • работа с S3;
  • отправка сообщения в Telegram;
  • публикация в WebSocket-сервер;
  • вызов CRM;
  • синхронизация с PMS;
  • работа с Excel;
  • отправка SMS.

Куда выносить:

  • Client;
  • Gateway;
  • Adapter;
  • Infrastructure Service.

Пример:

class PdfGeneratorClient
{
    public function generateFromHtml(string $html): PdfFile
    {
        // вызов внешнего сервиса генерации PDF
    }
}

Action только использует этот client:

class GenerateInvoiceAction
{
    public function handle(GenerateInvoiceData $invoiceData): PdfFile
    {
        $html = $this->invoiceRenderer->render(
            invoiceData: $invoiceData,
        );

        return $this->pdfGeneratorClient->generateFromHtml(
            html: $html,
        );
    }
}

Так Action описывает сценарий, а не детали HTTP-запроса к PDF-сервису.

Если общий кусок — проверка прав

Проверка прав не должна размазываться по Actions.

Плохо:

if ($user->id !== $booking->userId) {
    throw new AccessDeniedException();
}

И так в десяти Actions.

Лучше вынести в:

  • Policy;
  • Authorization Service;
  • Permission Checker;
  • Access Rule.

Пример:

class BookingAuthorizationService
{
    public function ensureCanChangeBooking(
        UserId $userId,
        Booking $booking,
    ): void {
        if (! $booking->belongsToUser(userId: $userId)) {
            throw new AccessDeniedException();
        }

        if (! $booking->canBeChanged()) {
            throw new BookingCannotBeChangedException();
        }
    }
}

Action:

class ChangeBookingDatesAction
{
    public function handle(ChangeBookingDatesData $bookingDatesData): Booking
    {
        return $this->transactionManager->run(
            callback: function () use ($bookingDatesData): Booking {
                $booking = $this->bookingRepository->getForUpdate(
                    bookingId: $bookingDatesData->bookingId,
                );

                $this->bookingAuthorizationService->ensureCanChangeBooking(
                    userId: $bookingDatesData->userId,
                    booking: $booking,
                );

                // продолжение сценария
            },
        );
    }
}

Здесь важно не путать authorization service с доменной моделью. Если правило относится к доступу конкретного пользователя к действию, оно может жить в application layer. Если правило относится к состоянию самой брони, оно ближе к домену.

Антипаттерны

1. Делать Action на каждый общий кусок

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

Плохо:

class PrepareBookingCommonDataAction
{
}

2. Вызывать Action из Action без понимания транзакций

Так легко получить скрытые вложенные сценарии и непонятные побочные эффекты.

3. Складывать всё в SomeService

class BookingService
{
    // 80 методов на все случаи жизни
}

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

4. Использовать Helper / Utils

class BookingHelper
{
}

Helper почти всегда означает: ответственность не определена.

5. Прятать бизнес-правила в Repository

Repository должен загружать и сохранять. Если в нём появляется бизнес-решение, границы начинают плыть.

6. Открывать транзакции внутри вложенных сервисов

Внутренний сервис не должен самовольно владеть транзакцией, если он является шагом внешнего сценария.

7. Смешивать чтение и доменное поведение

Отчётный query и изменение агрегата — разные задачи.

8. Делать общий сервис слишком абстрактным

Плохо:

class ProcessManager
{
}

Лучше:

class RoomAvailabilityService
{
}

Конкретное имя обычно лучше абстрактного тумана.

Практическая карта решений

ВопросОтвет
Это бизнес-правило?Entity / Aggregate / Domain Service
Это общий шаг сценария?Application Service
Это чтение данных?Query Service
Это сохранение модели?Repository
Это сложная запись в несколько таблиц?Persistence Service
Это подготовка DTO?Mapper / Factory
Это нормализация данных?Normalizer
Это внешний сервис?Client / Gateway / Adapter
Это уведомление?Event / Listener / Job
Это фоновая задача?Queue Job
Это проверка прав?Policy / Authorization Service
Это транзакция?Внешний Action / Transaction Manager

Как понять, что структура нормальная

Признаки хорошей структуры:

  • Action читается как сценарий;
  • название Action выражает use case;
  • общий сервис имеет конкретную ответственность;
  • транзакционная граница понятна;
  • бизнес-правила не спрятаны в контроллере;
  • repository не содержит бизнес-решений;
  • побочные эффекты вынесены в события или jobs;
  • DTO готовятся в mapper/factory;
  • инфраструктура спрятана за client/gateway;
  • классы можно тестировать отдельно.

Признаки проблем:

  • Action вырос до сотен строк;
  • появились CommonService, Helper, Utils;
  • Action вызывает цепочку других Actions;
  • транзакции открываются в нескольких вложенных местах;
  • бизнес-правила лежат в query-коде;
  • общий сервис называется по причине создания, а не по ответственности;
  • нельзя понять, кто владеет сценарием.

Итог

Action / Use Case — это сценарий приложения.

Он не обязан содержать каждую деталь. Он должен показывать flow и координировать части системы.

Если между Actions повторяется логика, не нужно автоматически создавать новый Action. Сначала определите смысл этой логики:

  • бизнес-правило — ближе к домену;
  • общий шаг сценария — application service;
  • чтение из базы — query service;
  • сохранение — repository или persistence service;
  • подготовка данных — mapper/factory;
  • внешний API — client/gateway;
  • побочный эффект — event/listener/job;
  • проверка прав — policy/authorization service.
Рейтинг
( Пока оценок нет )
Максим Гречушников/ автор статьи
Мне 40 лет, и больше половины жизни я занимаюсь разработкой. За это время я работал с корпоративными системами, highload-проектами, интеграциями, платежными решениями, внутренними платформами и legacy-кодом, который пережил не одного разработчика. Некоторые системы, кажется, переживут и нас. Моя специализация — PHP, Laravel, backend-архитектура, DDD, тестирование, рефакторинг и техническое лидерство. Мне интересны задачи, где нужно соединить код, бизнес, данные, процессы и здравый смысл. Последние годы я много работаю с LLM и AI-инструментами: использую их для проектирования, анализа кода, документации, тестов, ревью, работы с legacy и построения инженерных процессов. Для меня LLM — это не замена разработчику, а усилитель для тех, кто умеет думать системно. В блоге я пишу о Laravel, PHP, архитектуре, тестировании, инженерной практике, управлении разработкой и реальном применении AI в работе программиста.
Понравилась статья? Поделиться с друзьями: