В PHP-проектах часто используют классы под конкретные сценарии приложения. Где-то их называют Action, где-то UseCase, где-то ApplicationService, где-то CommandHandler.
Названия разные, но идея одна: отдельный класс описывает конкретное действие системы.
Например:
- создать бронь;
- оплатить заказ;
- отменить заявку;
- создать счёт;
- импортировать товары;
- опубликовать статью;
- изменить даты проживания;
- отправить заявку во внешнюю систему.
Сначала такой подход выглядит аккуратно. Контроллеры становятся тонкими, сценарии получают имена, код проще читать и тестировать.
Но потом появляется типичная ситуация:
- есть два разных Actions;
- каждый Action описывает отдельный сценарий;
- у каждого своя транзакция;
- внутри обоих повторяется кусок логики;
- эта логика работает с базой данных;
- выносить её в новый Action странно, потому что это не отдельный бизнес-сценарий.
И возникает вопрос:
Куда выносить повторяющуюся логику между Actions?
Короткий ответ:
Если общий код не является самостоятельным сценарием приложения, его не стоит превращать в новый Action. Место для такой логики определяется её смыслом: доменное правило, шаг сценария, запрос к базе, mapper, repository, infrastructure service, event или job.
Повторение кода само по себе не говорит, куда переносить логику. Сначала нужно понять, что это за логика.
- Коротко
- Что такое Action / Use Case
- Action — не контейнер для всего кода
- Почему общий код не всегда должен становиться новым Action
- Главное правило выбора места
- Если это бизнес-правило — ближе к домену
- Если это общий шаг сценария — Application Service
- Если это работа с базой данных — Repository или Query Service
- Repository
- Query Service
- Persistence Service
- Что делать с транзакциями
- Можно ли вызывать Action из Action
- Пример плохой структуры
- Пример нормальной структуры
- Как называть такие сервисы
- Если общий кусок — подготовка данных
- Если общий кусок — побочный эффект
- Если общий кусок — техническая операция
- Если общий кусок — проверка прав
- Антипаттерны
- 1. Делать Action на каждый общий кусок
- 2. Вызывать Action из Action без понимания транзакций
- 3. Складывать всё вSomeService
- 4. Использовать Helper / Utils
- 5. Прятать бизнес-правила в Repository
- 6. Открывать транзакции внутри вложенных сервисов
- 7. Смешивать чтение и доменное поведение
- 8. Делать общий сервис слишком абстрактным
- Практическая карта решений
- Как понять, что структура нормальная
- Итог
Коротко
Если между Actions повторяется код, ориентируйтесь на смысл этого кода.
| Повторяющаяся логика | Куда выносить |
|---|---|
| Бизнес-правило | Entity / Aggregate / Domain Service |
| Общий шаг сценария | Application Service |
| Чтение из базы данных | Query Service |
| Загрузка или сохранение модели | Repository |
| Сложное сохранение в несколько таблиц | Persistence Service |
| Подготовка DTO | Mapper / Factory |
| Нормализация входных данных | Normalizer |
| Внешний API | Client / 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.