25 мар. 2026 г.·6 мин чтения

Обработчики случаев использования для более компактного и понятного бэкенд‑кода

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

Обработчики случаев использования для более компактного и понятного бэкенд‑кода

Почему классы сервисов превращаются в хаос

Класс сервиса редко изначально плох. Он начинается с одной задачи, часто небольшой, например OrderService.cancel() или UserService.updateProfile(). Потом появляется новое правило, потом ещё одно, и класс становится местом по умолчанию для всех дополнительных условий.

Рост кажется безобидным сначала. Ещё один флаг, ещё одна зависимость, ещё один вспомогательный вызов. Через несколько месяцев тот же класс проверяет права, обновляет записи, отправляет письма, пишет логи, трогает биллинг и решает, какие побочные эффекты должны сработать.

Название часто усугубляет ситуацию. OrderService почти ничего не говорит о том, что действительно делает код. Когда класс имеет общее имя, люди продолжают добавлять поведение, потому что почти любое изменение кажется уместным там. Расплывчатое имя создаёт неясные границы.

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

Так теряются правила приложения. Разработчик должен добавить новое условие для отменённых заказов, открывает код и находит логику в контроллере, в сервисе, в репозитории и вспомогательную функцию validateOrderState(). Каждая часть выглядит наполовину правильной. Ничто не кажется безопасным для изменения.

Команды реагируют защитно. Они копируют проверки вместо того, чтобы переместить их. Добавляют булевы флаги, потому что не доверяют старому потоку. Зависимости распространяются по той же причине. Класс, который раньше требовал только клиента базы данных, теперь подключает биллинг, уведомления, аудит и feature flags.

Результат — не просто большие файлы. Решения замедляются. Люди колеблются, потому что не могут понять, где начинается одно действие и где заканчивается другое. Обычно в этот момент обработчики случаев использования начинают выглядеть гораздо чище.

Чем занимается обработчик случая использования

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

Такой небольшой объём меняет восприятие кода. Вместо того чтобы открывать расплывчатый UserService или OrderManager и гадать, где живёт правило, вы открываете класс, имя которого говорит о результате. InviteTeamMember легче воспринимать, чем TeamService, потому что имя класса уже объясняет, зачем он нужен.

Входы и выходы тоже проще проследить. Обычно обработчик принимает небольшой объект команды или запроса, выполняет работу и возвращает простой результат. Когда кто‑то спрашивает: "Что происходит, когда клиент меняет тариф?", вы можете проследить этот поток, не прыгая через пять общих классов.

Почему это проще на практике

Бизнес‑правила остаются рядом с действием, а не разбросаны по вспомогательным модулям, сервисам и контроллерам. Это важнее, чем многие команды признают. Когда правила живут рядом с use case, их можно менять с меньшим страхом.

Хороший обработчик делает очевидными четыре вещи: какое действие происходит, какие данные нужны, какие правила должны пройти и какой результат возвращается. Если разработчик может открыть один файл и ответить на эти вопросы, то код, вероятно, в приличном виде.

Этот подход также облегчает очистку зависимостей. Обработчик зависит только от того, что нужно для конкретного действия, а не от всех инструментов, которыми когда‑то пользовался модуль. Oleg Sotnikov часто действует так и на уровне архитектуры: убрать лишнее, уменьшить число движущихся частей и держать каждый компонент достаточно маленьким, чтобы его назначение оставалось очевидным.

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

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

Разрастание сервисов обычно начинается с безобидных имён. Добавили OrderService, потом PaymentService, потом UserService, и каждый продолжает поглощать работу. Со временем имя класса почти ничего не говорит.

Раздутый сервис часто смешивает несколько задач в одном месте. Он загружает данные, проверяет права, применяет бизнес‑правила, вызывает другие сервисы, отправляет письма и пишет логи. Если класс OrderService может отменить заказ, вернуть платёж, пополнить склад, уведомить поддержку и обновить аналитику, то проблема не в размере одна — никто не может сказать, где начинается одно правило и где заканчивается другое.

Пару предупреждающих знаков можно увидеть снова и снова. Методы перескакивают между сервисами и затем снова вызывают первый. Маленькое изменение правила требует правок в контроллерах, сервисах и репозиториях. Тесты требуют моков для половины приложения, чтобы покрыть одно действие. Ревьюеры постоянно спрашивают, где живёт правило. Новички читают три файла, прежде чем довериться одному изменению.

Циклические вызовы — сильный сигнал. Если OrderService вызывает PaymentService, который вызывает InventoryService, а тот снова вызывает OrderService, код перестаёт читаться как бизнес‑действие и начинает читаться как сантехника.

Ещё один признак — страх. Когда разработчики избегают менять класс, потому что каждая правка ломает что‑то далеко, сервис превратился в мешанину. Тесты обычно показывают это первыми. Простая ситуация вроде "отменить один заказ" не должна требовать поддельных платежных шлюзов, почтовиков, обновлений запасов, feature flags и пользовательских сессий — только если действие действительно этого не требует.

Имена тоже выдают. Методы вроде process(), handle(), executeTask() или updateEverything() скрывают намерение. Если код не может ответить на вопрос "в каком файле происходит отмена заказа?" одним файлом, разрастание сервисов уже пришло.

Как шаг за шагом перенести один поток

Не начинайте с грандиозного переписывания. Выберите одно действие, которым люди пользуются каждый день. Частые пути быстро выявляют проблемы и дают полезную обратную связь. Хороший кандидат — что‑то простое и распространённое, например изменение адреса для биллинга, одобрение запроса или создание черновика.

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

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

Теперь создайте один обработчик с одним методом входа. Назовите его по действию, а не по расплывчатой роли. ChangeBillingEmailHandler.handle(...) говорит правду быстрее, чем AccountService.update(...).

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

Не вырывайте старый код в первый же день. Пусть старый сервис некоторое время остаётся и вызывает новый обработчик через ту же публичную точку. Это сохраняет стабильность вызывающих сторон, пока вы доказываете работу нового пути. Немного некрасиво, но краткосрочное дублирование дешевле, чем сломанный перепис.

Тесты решают, когда можно перейти окончательно. Добавляйте или обновляйте тесты вокруг поведения, а не вокруг старой формы класса. Когда обработчик проходит тесты и старая входная точка только перенаправляет вызов, переключите вызывающих, удалите оболочку и удалите мёртвые ветки. Обычно этого достаточно, чтобы убрать один запутанный поток без командных споров.

Простой пример: отмена заказа

Cut Costly Complexity
Oleg helps small teams simplify backend architecture before complexity slows releases.

Представьте, что клиент просит отменить заказ через десять минут после оплаты. Посылка ещё не отправлена. Это должно быть просто, но часто прячется внутри огромного OrderService с расплывчатыми методами вроде updateOrder() или handleChange().

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

Проверки читаются как обычные бизнес‑правила. Находится ли заказ ещё в состоянии, допускающем отмену? Был ли платёж уже списан? Находится ли запрос в пределах разрешённого окна по времени? Если на любой из вопросов ответ "нет", обработчик останавливается и возвращает причину.

Если все проверки проходят, обработчик переводит статус заказа в cancelled, фиксирует, кто запросил отмену, и запускает возврат средств. Этот возврат может выполняться сразу или передаваться в воркер платежей, но решение о запуске всё равно остаётся за обработчиком.

CancelOrderHandler(orderId, customerId, requestedAt):
  load order
  if order not found -\u003e return NotFound
  if order already cancelled -\u003e return AlreadyCancelled
  if order already shipped -\u003e return TooLate
  if requestedAt is outside cancellation window -\u003e return TooLate
  if payment exists -\u003e start refund
  mark order as cancelled
  return Cancelled

API не нужно исследовать половину системы, чтобы догадаться, что произошло. Оно получает один результат и мапит его в ответ: Cancelled — успех, TooLate — отправка уже началась или окно закрыто, AlreadyCancelled — показать текущий статус, NotFound — вернуть ошибку о несуществующем заказе.

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

Имена, которые понятны с первого взгляда

Плохие имена заставляют маленький код казаться большим. Когда кто‑то открывает файл OrderService, ему всё ещё нужно прочитать половину класса, чтобы понять, что он делает. Имя CancelOrder рассказывает историю до первой строки кода.

Имя должно совпадать с тем, как люди произносят действие в планировании, в чатах поддержки и в отчётах об ошибках. Команды говорят "отменить заказ", "отправить счёт" и "утвердить возврат". Они почти никогда не говорят "запустить OrderService".

Простые глаголы держат код честным. Они ускоряют ревью: читатель сравнивает имя класса с правилом внутри. Если CancelOrder ещё и отправляет маркетинговую рассылку, обновляет склад и пересчитывает баллы лояльности, несоответствие очевидно.

Пара замен имён быстро очищает ситуацию: OrderService становится CancelOrder, PaymentProcessorCapturePayment, UserManagerSuspendUser, NotificationUtilitySendPasswordResetEmail.

Имена вспомогательных модулей требуют такого же подхода. OrderCancellationPolicy ясно, если он отвечает за проверку возможности отмены. DateHelper не ясен — в нём может скрываться что угодно.

Слова вроде Manager, Processor, Handler и Utility часто размывают реальную задачу. Иногда Handler подходит, если проект использует его для каждого use case. Даже в этом случае действие должно нести смысл. CancelOrderHandler всё равно лучше, чем OrderHandler.

Простой тест помогает: скажите имя в предложении, которое вы бы использовали с коллегой: "Баг, вероятно, в CancelOrder." Если это звучит естественно, имя, скорее всего, хорошее. Если вы никогда не скажете "Проверь OrderManager", код просит читателя переводить расплывчатые метки в реальные действия.

Чёткие имена сами по себе не исправляют плохой дизайн. Но они делают плохой дизайн заметнее, и именно там обычно начинается очистка.

Как обрезать зависимости

Prepare Your Team for AI
Set up cleaner code paths before you push AI tools deeper into development.

Большинство раздутых сервисов имеет один и тот же запах: один конструктор, десять аргументов, и половина из них сидит там "на всякий случай". Обработчик случая должен принимать только то, что нужно для одного действия. Если поток отмены заказа затрагивает заказы, платежи и время, передайте эти три зависимости. Оставьте почту, индексирование поиска и отчётность вне, пока обработчик реально их не использует.

Хороший жёсткий тест: удалите одну зависимость и спросите, сможет ли обработчик ещё выполнять свою задачу. Если да — убирайте её. Маленькие конструкторы вынуждают к честному коду. Они также упрощают тесты, потому что вы перестаёте мокать объекты, которые не участвуют в действии.

Разделяйте чтение и запись как можно раньше. Получение сводки заказа для экрана и отмена заказа — разные задачи. Запрос для экрана может использовать read model, оптимизированную для отображения. Действие отмены — write side с проверками и обновлениями, которые ему нужны. Когда один класс пытается делать и то, и другое, зависимости быстро накапливаются.

Общие правила приложения создают следующую проблему. Команды часто держат их внутри большого сервиса, потому что многие потоки нуждаются в них. Вынесите такие правила в маленькие объекты‑политики. CancellationPolicy должен отвечать на один вопрос: "Можно ли сейчас отменить этот заказ?" Обработчик вызывает политику и затем выполняет действие. Вы получаете повторное использование без того, чтобы тянуть весь сервис в каждый поток.

Одна привычка наносит больше вреда, чем кажется: передавать вокруг большой контейнер сервисов. Это удобно, потому что любой инструмент доступен. Но это скрывает реальную стоимость каждого действия. Обработчик, принимающий AppServices, ничего не говорит. Обработчик, принимающий OrderRepo, PaymentGateway, Clock и CancellationPolicy, говорит почти всё с первого взгляда.

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

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

Ошибки, которые усложняют переписывание

Переписывание сбивается с пути, когда форма кода меняется, но не смысл. Команды переносят файлы из services/ в use-cases/, оставляют имена вроде OrderService и считают работу сделанной. Зависимости остаются запутанными, методы — расплывчатыми, и никто не понимает, где живёт правило.

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

Скрытые записи в базу быстро усугубляют проблему. Хелпер prepareOrder() звучит безобидно. Если он также обновляет склад, создаёт запись о возврате и пишет строку аудита, следующий читатель обречён. Держите записи в базе рядом с use case. Когда кто‑то откроет обработчик, он должен видеть, что меняется и в каком порядке.

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

Разделение проще, чем кажется. Правило — в обработчике. Ретраи — рядом с внешним вызовом, который может падать. Логи и метрики — вокруг обработчика, а не внутри каждой ветки. Делайте вспомогательные методы маленькими и скучными.

Самая большая ошибка — переписать весь бэкенд разом. Это красиво на доске планирования и ужасно в жизни. Вы теряете ясный «до‑после», ревью становятся запутанными, а баги распространяются по не связанным потокам.

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

Быстрые проверки перед merge

Make Reviews Faster
Clearer use cases help your team open fewer files and trust changes sooner.

Переписывание может выглядеть аккуратно в diffs, но оставить ту же старую мешанину. Перед merge прочитайте новый код как посторонний. Если поток всё ещё требует догадок, очистка не завершена.

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

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

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

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

Что делать дальше

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

Затем измерьте что‑то небольшое и реальное. Отслеживайте, сколько времени ревьюеры тратят на PR, сколько файлов они открывают и сколько уточняющих вопросов задают. Если время ревью упадёт с 35 минут до 15, команда скорее поверит изменениям, чем после долгих архитектурных дебатов.

Короткое правило команды помогает больше длинного документа: называйте обработчики по действию, давайте каждому обработчику одну задачу, держите бизнес‑проверки в обработчике, а не в общем сервисе, и инжектируйте только те зависимости, которые реально использует поток.

Применяйте это правило к новой работе в первую очередь. Команды часто пытаются исправить старый код повсеместно, устают и бросают дело на полпути. Лучше подойти проще: каждая новая фича и каждый изменяемый поток следуют новому стилю, а старый код меняется только тогда, когда кто‑то уже должен его тронуть.

Это также сокращает споры. Когда разработчик добавляет новый общий сервис, команда может задать один вопрос: "Какой use case обрабатывает этот код?" Если ответ расплывчат, имя, вероятно, тоже расплывчато.

Если команда застряла между полным переписыванием и бездействием, вторая точка зрения может помочь. Oleg Sotnikov на oleg.is часто работает со стартапами и малыми командами в роли Fractional CTO, ревьюя архитектуру по одному потоку и убирая ненужную сложность прежде, чем она распространится.

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

Часто задаваемые вопросы

How do I know a service class has become too big?

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

When should I use a use case handler instead of a service?

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

Where should business rules live?

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

What is the safest way to replace one service method?

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

How should I name a use case handler?

Называйте класс по действию, которое он выполняет. CancelOrder, ApproveRefund и SendInvoice говорят о цели быстрее, чем OrderService или PaymentManager, потому что читатель уже понимает назначение до чтения кода.

How do I cut down dependencies in a handler?

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

Should controllers still contain some business logic?

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

What should I test after moving code into a handler?

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

What do I do with logic that several flows share?

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

Do I need to rewrite every service class?

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