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

Примеры в статье будут на Laravel (PHP 8.3), однако сам подход не зависит от конкретного фреймворка или языка программирования - его можно применять в любом backend-проекте.

Также сразу отмечу:

это не волшебная таблетка, которая исправит все проблемы архитектуры.

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

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

Что такое UseCase

Если говорить просто, UseCase - это один законченный бизнес-сценарий системы.

Например:

  • создать заказ

  • зарегистрировать пользователя

  • авторизовать пользователя

  • изменить статус заявки

  • сформировать счёт

UseCase описывает не техническую операцию, а действие системы с точки зрения бизнеса.
То есть не «записать строку в базу», а именно: пользователь оформляет заказ.

С точки зрения архитектуры:

  • это уровень приложения (Application Layer)

  • он управляет сценарием — что происходит и в каком порядке

  • содержит только управляющую логику

  • делегирует бизнес-правила доменным сервисам и сущностям

Если внутри сценария появляются сложные расчёты или правила — их нужно выносить в доменные компоненты.

Например, подсчёт стоимости заказа можно вынести в PricingService.

Проблема, которую он решает, проявляется с ростом проекта.

Пока система маленькая, всё выглядит понятно. Но со временем логика начинает расползаться: часть в контроллерах, часть в сервисах. В какой-то момент становится сложно ответить на простой вопрос — что вообще умеет система. Код есть, методы есть, но целостной картины нет.

В итоге становится сложно ответить на простой вопрос:

Что вообще умеет делать наше приложение? - да именно такой вопрос возникает у меня когда я захожу на свои старые проекты где надо что-то исправить.

UseCase вводит понятную структуру: система описывается через явные бизнес-сценарии, а не через абстрактные сущности вроде Service, Manager или Helper.

В результате архитектура становится читаемой и понятной на уровне бизнес-логики.

Есть ли ограничения у UseCase?

В большинстве случаев не рекомендуется вызывать один UseCase из другого, чтобы:

  • не создавать скрытые зависимости между сценариями

  • не превращать UseCase в переиспользуемые "блоки логики"

Однако в сложных сценариях допустим вызов одного UseCase из другого, если:

  • нет циклических зависимостей

  • сохраняется явность сценариев

Если появляется необходимость повторного использования логики - её стоит вынести в отдельные сервисы или доменные компоненты. Эти компоненты затем могут использоваться в разных UseCase.

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

Где вызываются UseCase

UseCase — это точка входа в бизнес-логику приложения. И вызываться он может практически откуда угодно:

  • Controller

  • Console Command

  • Job

  • Event Listener

Проще всего представить это так:

UseCase — это ручки, через которые внешний мир взаимодействует с бизнес-логикой приложения.

Почему одного Service-слоя может быть недостаточно?

На старте проекта одного Service-слоя действительно достаточно — так делают почти все. Проблема появляется позже, когда бизнес-логика начинает расти.

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

Но со временем появляются дополнительные шаги:

  • проверить остатки

  • применить скидку

  • списать баланс

  • отправить уведомление

  • записать аналитику

И постепенно появляется класс OrderService, в котором:

  • много зависимостей

  • много методов

  • много побочных эффектов

  • сложно читать код

  • сложно писать тесты

Да, я сам писал сервисы на 1000+ строк с десятками методов — это распространённая ситуация в реальных проектах. И в каком-то смысле это логично: бизнес-процесс растёт вместе с продуктом. Но такой рост приводит к усложнению кода и потере управляемости.

Со временем сервис может превратиться в God-object - класс с большим количеством методов, зависимостей и побочных эффектов.

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

Вы можете сказать: «Можно же просто разбить сервис на более мелкие, например StoreOrderService, и использовать его везде».

И это действительно частично помогает. Но остаётся ключевой вопрос: где границы бизнес-сценария? Какие шаги входят в «создание заказа», а какие — нет?

Без явного выделения UseCase эти границы остаются неочевидными, и логика всё равно со временем начинает расползаться по коду.

UseCase предлагает простое правило:

один UseCase = один бизнес-процесс

А повторяющаяся логика при этом выносится в отдельные сервисы и доменные компоненты. Это позволяет сохранить код управляемым даже по мере роста сложности.

Структура UseCase

Я обычно разделяю UseCase на три части:

  • Handler

  • DataInput

  • DataOutput

Границы UseCase:

  • управляет последовательностью выполнения сценария (orchestration)

  • может содержать условия выполнения шагов

  • не должен содержать доменную бизнес-логику (инварианты, расчёты)

Доменная логика должна находиться в: сущностях (Entities), ValueObjects, доменных сервисах. К примеру как я писал ранее PricingService.

Пример: создание заказа

StoreOrderDataInput - объект входных данных

  1. Без логики

  2. Только типизированные данные

  3. Данные могут быть провалидированы на уровне запроса (например, Request) или внутри DataInput, валидируя данные в DataInput вы создаёте единую точку проверки данных в вашем приложении, ещё лучше использовать ValueObject. Но важно выбрать единый подход в проекте

  4. Желательно делать данные неизменяемыми (immutable), если это поддерживается языком

class StoreOrderDataInput
{
    public function __construct(
        public readonly int $userId,
        public readonly array $items,
    ) {}
}

StoreOrderDataOutput - объект результата выполнения

  1. Рекомендуется не возвращать Eloquent напрямую, а формировать явный контракт (DTO или аналог)

  2. Формируем явный контракт

class StoreOrderDataOutput
{
    public function __construct(
        public readonly int $orderId,
        public readonly string $status,
    ) {}
}

StoreOrderHandler - сам сценарий

class StoreOrderHandler
{
    public function __construct(
        private OrderRepository $orders
    ) {}

    public function handle(StoreOrderDataInput $input): StoreOrderDataOutput
    {
        $order = new Order(
            userId: $input->userId,
        );

        $orderSaved = $this->orders->save($order);

        return new StoreOrderDataOutput(
            orderId: $orderSaved->id,
            status: $orderSaved->status
        );
    }
}

Использование в Laravel

Контроллер становится максимально простым:

class OrderController
{
    public function store(Request $request, StoreOrderHandler $handler)
    {
        $input = new StoreOrderDataInput(
            userId: auth()->id()
        );

        $output = $handler->handle($input);

        return response()->json($output);
    }
}

Получение DTO можете вынести в Request создав для него отдельный класс StoreOrderRequest. Или используя библиотеку laravel-spatie-data вы можете использовать ваши DTO в качестве Request - в таком случае валидация будет автоматической.

Создание заказа требует дополнительной логики? - Не проблема!

Всё, что относится к сценарию «создание заказа», остаётся внутри одного Handler.

А переиспользуемая логика выносится в отдельные классы: PaymentService, DiscountService, StockService, NotificationService.

Handler начинает выполнять оркестрацию - он управляет последовательностью шагов сценария.

class StoreOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentService $payment,
        private DiscountService $discounts,
        private StockService $stock,
        private NotificationService $notifications,
    ) {}

    public function handle(StoreOrderDataInput $input): StoreOrderDataOutput
    {
        $this->payment->charge($input->userId);
        $this->discounts->apply($input);
        $this->stock->reserve($input->items);

        $order = $this->orders->create($input);

        $this->notifications->sendOrderCreated($order);

        return new StoreOrderDataOutput(
            orderId: $order->id,
            status: $order->status
        );
    }
}

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

Как я храню UseCase в проекте

В Laravel я создаю структуру:

app/UseCases/V1

Как вы видите добавляется версия сценариев. В будущем это помогает безопасно менять контракты для ваших сценариев, но если вдруг ваши сценарии не меняются то вы как минимум подстелили себе солому.

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

В большинстве случаев это имеет смысл только если:
1. У вас публичный API
2. Контракты сценариев меняются и нужно поддерживать старые версии

После этого каждая директория внутри отвечает на вопрос:

Что умеет делать система?

Например:

  1. app/UseCases/StoreOder- создание заказа

  2. app/UseCases/CancelOrder- отмена заказа

  3. app/UseCases/RegisterUser - регистрация пользователя

  4. app/UseCases/AuthorizeUser- авторизация пользователя

Структура проекта начинает отражать бизнес-возможности системы (что она умеет делать), а не технические детали реализации

Тестирование UseCase? - это ещё проще!

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

Пример теста StoreOrderHandler

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class StoreOrderFeatureTest extends TestCase
{
    use RefreshDatabase;

    public function testUserCanCreateOrder(): void
    {
        $user = User::factory()->create();

        $payload = [
            'items' => [
                ['id' => 10, 'qty' => 2],
                ['id' => 15, 'qty' => 1],
            ],
        ];

        $response = $this->actingAs($user)
            ->postJson('/api/orders', $payload);

        $response->assertStatus(200);

        $response->assertJsonStructure([
            'orderId',
            'status'
        ]);

        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id
        ]);
    }
}

Тестирвоание лёгкое и понятное! Добавился модуль? - не беда, просто дополните свой тест проверкой того что данный модуль выполняет свою работу!

Итоги

Что нам это дало?

  1. Явные контракты — теперь мы чётко видим и понимаем, какие данные принимает приложение при создании заказа и какие данные возвращает.

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

  3. Удобство тестирования — теперь можно писать интеграционные тесты уровня сценария и проверять весь путь пользователя, не опасаясь, что изменения сломают бизнес-процесс при деплое.

  4. Быстрое включение новых разработчиков в проект — структура приложения описана через бизнес-сценарии, поэтому новым участникам команды проще понять, что именно делает система и где находится нужная логика.

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

Большое спасибо что дочитали!

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