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

Почему эта граница вызывает путаницу
Граница путана потому, что оба слоя часто работают с одними и теми же объектами. Сервис приложения может загрузить Order, вызвать у него метод, сохранить его и отправить сообщение. Доменный сервис может работать с тем же Order и некоторыми из тех же вспомогательных объектов. На первый взгляд код может выглядеть почти одинаково.
Вот почему одни только имена мало помогают. Называть что‑то «приложением» или «доменом» ничего не решает, а размер класса говорит ещё меньше. Одна крошечная функция может содержать правило, которое решает, уйдут ли деньги со счёта. Десять коротких методов могут ничего не делать, кроме как извлекать данные, вызывать другой код и оборачивать работу в транзакцию.
Реальная цена проявляется позже. Когда бизнес‑правила оказываются в сервисах приложения, они начинают рассеиваться. Веб‑запрос подтверждает заказ одним способом, админ‑скрипт делает это по‑другому, а фоновая задача вообще пропускает одно условие. Тесты усложняются: чтобы проверить правило, которое должно жить рядом с бизнес‑моделью, нужны моки, хранилище и настройка.
Обратная ошибка приносит другой вид боли. Если доменные сервисы начинают заниматься почтой, повторными попытками, транзакциями или вызовами API, бизнес‑модель становится хуже читаемой и сложнее для повторного использования. Простое правило оказывается похоронено в коде процессов.
Лучший тест намного проще: спросите, какого рода решение принимает код. Если он решает поведение бизнеса — ему место в домене. Если он координирует шаги вокруг этого поведения — в слое приложения.
Этот один вопрос быстро расставляет многие споры.
Одно правило
Пользуйтесь простым правилом: если код решает бизнес‑исход, помещайте его в домен. Если он только координирует шаги — оставляйте в приложении.
Звучит скромно, но это решает большинство дебатов. Метод, который решает, может ли заказ быть отправлен, можно ли вернуть деньги или получает ли клиент скидку, принимает бизнес‑решение. Такая логика должна находиться рядом с моделью, чтобы другие части системы могли безопасно её переиспользовать.
Код приложения выполняет другую задачу. Он загружает данные, открывает транзакции, вызывает доменные объекты, сохраняет изменения и запускает побочные эффекты, например письма или события. Он управляет потоком. Ему не следует определять, что значит «одобрен», «валиден» или «разрешён».
Хороший способ заметить разницу — прочитать названия тестов, которые вы хотели бы написать. Если тест звучит как бизнес‑правило — логика должна быть в домене. Если тест похож на рабочий процесс — он принадлежит слою приложения.
Обычно это указывает на доменную логику:
- «Отклонить возврат, если покупка старше 30 дней»
- «Разрешать апгрейд только для активных подписок»
- «Блокировать выплату, если проверка аккаунта не прошла»
Обычно это указывает на логику приложения:
- «Загрузить аккаунт, вызвать политику, сохранить результат, опубликовать событие»
- «Открыть транзакцию, создать счёт, отправить квитанцию»
- «Повторить платёжный вызов один раз, затем зафиксировать ошибку»
Ещё одна проверка помогает, когда код сидит «посредине». Спросите, что будет важно, если вы смените базу данных, очередь или веб‑фреймворк. Если правило останется тем же — это, вероятно, поведение домена. Если код в основном изменится вместе с процессом или интеграцией — это оркестровка приложения.
Это правило также упрощает перенос и тестирование кода. Бизнес‑правила остаются простыми. Слой приложения остаётся тонким. Когда сервис превращается в длинный метод, полный if про цены, одобрения, лимиты или право на действие, это обычно сигнал о том, что решения нужно вынести ближе к домену.
Чем должны заниматься application services
Сервисы приложения координируют работу. Они принимают запрос извне, переводят данные в домен и возвращают результат наружу. Это код, который говорит: «запустить этот процесс сейчас».
Процесс обычно начинается с команды, API‑вызова или действия в UI. Сервис приложения читает вход, проверяет, достаточно ли данных для продолжения, и загружает вовлечённые доменные объекты. Если запрос говорит «одобрить заказ 123», сервис приложения получает этот заказ и любые другие данные, которые понадобятся доменному методу.
Дальше работа в основном — последовательность и сантехника: принять запрос, загрузить агрегаты или вспомогательные записи, вызвать доменные методы в нужном порядке, сохранить изменения и инициировать побочные эффекты.
Последняя часть важна. Отправка письма, публикация события, вызов платёжного провайдера или запись аудита часто относится сюда. Эти действия часть процесса, но это не само бизнес‑поведение.
Хороший сервис приложения остаётся тонким. Он может делать простые проверки, вроде «существует ли запись?» или «есть ли у пользователя доступ начать это действие?». Он также может управлять границами транзакций и повторными попытками. Ему не следует принимать политические решения в языке бизнеса.
Если вы видите логику вроде «если премиум‑клиент и сумма заказа выше лимита и возраст аккаунта меньше 30 дней», значит код уходит не в своё место. Это читается как бизнес‑правило. Перенесите его в домен и пусть сервис приложения просто вызывает его.
Полезный тест — прочитать метод вслух. Если в основном глаголы звучат как процессные — загрузить, вызвать, сохранить, опубликовать — то, скорее всего, всё в порядке. Если метод начинает говорить бизнес‑терминами — одобрить, квалифицировать, отменить, запретить, резервировать — домен должен владеть этим поведением.
Чем должны заниматься domain services
Доменные сервисы содержат правила, которые отвечают на бизнес‑вопросы, которые не решает одна сущность. Они решают, что может произойти, что не должно происходить и какой исход соответствует правилам.
Хороший доменный сервис говорит языком бизнеса. Он работает с заказами, счетами, балансами, лимитами, датами и политиками. Ему не нужно знать про HTTP‑запросы, поля форм, таблицы базы, очереди сообщений или экран, который его вызвал.
Бизнес‑правила, охватывающие несколько объектов
Некоторые правила удобно помещаются в одну сущность. Другим нужны факты из нескольких сущностей или value‑object. Здесь доменный сервис уместен.
Возьмём правило возврата. Логике может потребоваться проверить статус заказа, состояние платежа, окно для возврата и условия контракта клиента, прежде чем решить, разрешён ли возврат. Ни контроллер, ни репозиторий не должны принимать это решение. Правило относится к бизнесу.
Здесь же вы защищаете инварианты, которые простираются через объекты. Если модель говорит, что перевод с одного счёта нельзя делать, если он опустит баланс ниже минимума, и что приёмный счёт тоже не должен нарушать свои лимиты, доменный сервис может обеспечить соблюдение обоих правил вместе.
Результаты должны звучать как бизнес
Возвращаемое значение важно. Доменный сервис должен возвращать то, что бизнес читает без перевода: «Одобрено», «Отклонено», «Возврат невозможен — прошло более 30 дней» или «Перевод превышает суточный лимит».
Сырые коды статуса, SQL‑ошибки и UI‑сообщения не относятся сюда. Код приложения может взять доменный результат и решить, как его представить, зафиксировать или сохранить. Бизнес‑решение остаётся чистым.
Полезный тест: если вы можете запустить сервис в юнит‑тесте с простыми объектами и без базы данных, веб‑фреймворка или клиента API, то, скорее всего, вы поместили его верно. Если сервису нужны заголовки запросов или имена таблиц для работы — он делает сантехническую работу, а не доменную.
Простой пример с утверждением заказа
Представьте поток утверждения заказа для оптового магазина. Менеджер продаж отправляет заказ на 40 единиц. Система должна ответить на два бизнес‑вопроса перед одобрением: достаточно ли у нас запасов и достаточно ли у клиента кредита?
Сервис приложения координирует работу. Он загружает заказ, спрашивает у инвентаря текущее наличие, получает данные о кредите клиента и передаёт эти факты в домен.
Проще говоря: «забрать данные, вызвать бизнес‑правило, сохранить результат». Если сервис приложения остаётся на этом уровне, его легко читать.
Правило утверждения само по себе принадлежит доменной модели или доменному сервису. Правило может звучать так: одобрять заказ только если доступного запаса хватает на количество, а оставшийся кредит клиента покрывает сумму заказа. Если одна из проверок не проходит — отклонить заказ и записать причину.
Если правило естественно помещается в сущность Order, положите его туда. Если оно требует логики, охватывающей Order, кредит клиента и политику инвентаря, доменный сервис будет чище. Суть остаётся: домен решает, что значит «одобрено».
Платёж и почта остаются вне домена. Это побочные эффекты, не бизнес‑смысл. После того как домен скажет «одобрено», сервис приложения может попросить платёжного провайдера авторизовать платёж и попросить рассыльщик отправить подтверждение. Если одобрение не прошло, он может пропустить платёж и отправить другое сообщение.
Такой раздел делает тесты маленькими. Правила утверждения можно тестировать простыми объектами и несколькими числами:
- stock: 50
- credit left: $1,200
- order total: $900
- result: approved
Затем другой тест, где stock = 12 или кредит слишком мал, и ожидаем отклонение.
Сервис приложения получает другой тип теста. Проверяйте, что он загружает данные, вызывает домен один раз, сохраняет заказ и запускает платёж или отправку письма только после того, как домен принял решение.
Как по шагам разместить новую логику
Большинство путаницы на уровне сервисов начинается с одной плохой привычки: люди пишут код, прежде чем смогут сформулировать правило простым языком.
Начните с предложения, которое поймёт не‑разработчик. Например: «Возврат разрешён только если платеж прошёл, окно для возврата ещё открыто, и товар не помечен как распродажа окончательно.»
Это предложение уже указывает в правильном направлении. Слова вроде «разрешён», «должен», «только если», «нельзя» обычно сигналят о бизнес‑решении. Эта часть относится к домену. Код, который загружает записи, вызывает другие системы, пишет логи или отправляет сообщения, не принимает решение — он лишь помогает ему случиться.
Практическая последовательность выглядит так:
- Напишите правило одной короткой бизнес‑фразой.
- Выделите точные слова, которые решают да/нет.
- Спрашивайте, какие объекты знают факты, лежащие в основе этого решения.
- Поместите правило рядом с этими объектами или в доменный сервис, если несколько объектов участвуют.
- Оставьте вызовы базы, повторы, логирование и отправку сообщений в слое приложения.
Третий шаг чаще всего проясняет спорные случаи. Если один объект знает нужные факты — положите поведение туда. Если несколько объектов хранят часть истины — доменный сервис чаще будет лучшим местом. В примере с возвратом RefundPolicy может потребовать факты из платежа, заказа и правил по продукту. Сервис приложения может получить эти объекты и передать их, но не должен решать, проходит ли возврат.
Быстрый «тест на запах» помогает: уберите в голове базу, очередь и логгер из метода. Если правило всё ещё имеет смысл — вы смотрите на доменную логику. Если метод разваливается, потому что всё, что он делает — вызывать API, сохранять данные и публиковать события, — это слой приложения.
Команды, которые действуют по этой привычке, обычно получают меньшие сервисы, понятные тесты и меньше странных вспомогательных классов.
Ошибки, которые толкают логику не туда
Большинство плохого дизайна сервиса приложения начинается с небольшой халтуры. Команда помещает одно решение в сервис приложения, потому что так быстрее, затем ещё одно, затем ещё. Через месяц сервис приложения выполняет реальную бизнес‑работу, а доменные объекты — не больше, чем контейнеры данных.
Так происходит потому, что сервисы приложения легко доступны. Они уже говорят с репозиториями, отправляют письма, вызывают API и запускают задачи. Как только бизнес‑решения туда попадают, код превращается в длинный скрипт. Он всё ещё работает, но правила становится трудно найти и сложно доверять.
Обратная ошибка тише. Команды выдумывают доменный сервис для методов, которые только прокидывают данные к сущности или вызывают один метод и возвращают результат. Это добавляет класс, имя и никакого смысла. Если правило легко помещается в одну сущность или value‑object — оставьте его там.
Ещё одна распространённая проблема — когда репозитории или внешние API протекают в бизнес‑правила. Если правило звучит «одобрять заказ только если у клиента нет просроченных задолженностей», само правило должно остаться в домене. Загрузка клиентской записи — работа приложения. Запрос к платёжному шлюзу ради проверки смешивает политику с ввод‑выводом и быстро делает тесты грязными.
Команды также прячут решения в обработчиках событий и фоновых джобах. Джоб должен выполнять отложенную работу, а не контрабандой нести политику. Если обработчик решает, считается ли заказ одобренным, никто не знает, где живёт реальное правило. Вы читаете сервис приложения, затем событие, затем воркер — и всё равно теряете часть поведения.
Более серьёзный беспорядок появляется, когда одно правило расползается по трём классам. Сервис приложения проверяет статус, доменный сервис проверяет лимиты, а адаптер репозитория смотрит на специальный флаг. Каждая часть выглядит небольшой, но правило имеет смысл только когда вы читаем все три вместе.
Сигналы тревоги появляются рано:
- Сервис приложения содержит много
ifпро бизнес‑политику. - Доменный сервис в основном только перенаправляет вызовы и не принимает решения.
- Правило требует кода базы данных или API для выполнения.
- Нельзя указать одно место и сказать: «здесь решается одобрение».
У хорошего правила должен быть один дом. Пусть сервис приложения координирует шаги. Пусть доменная модель или настоящий доменный сервис принимают решение. Храните сохранение, очереди и HTTP‑вызовы вне этого решения.
Быстрая чек‑лист перед коммитом
Перед тем как закоммитить новый сервис, остановитесь на минуту и подвергните код стресс‑проверке.
Можете ли вы объяснить правило, не говоря ни слова про контроллер, очередь, cron‑задачу или API‑эндпоинт? Если да — правило, вероятно, в домене.
Останется ли правило тем же, если вы смените хранение завтра? Правило вроде «заказы свыше $5,000 требуют ручного одобрения» не изменится при переходе с PostgreSQL на другую базу.
Выбирает ли этот код исход или просто выполняет шаги по порядку? Выбор обычно значит поведение бизнеса. Выполнение шагов — оркестровка приложения.
Можно ли протестировать правило простыми объектами и почти без моков? Если тест требует почты, платёжей, логов или брокера сообщений, чтобы доказать решение — логика не там, где надо.
Если убрать почту, платежи и логи, решение всё ещё будет иметь смысл? Должно. Эти действия реагируют на результат, но не определяют его.
Небольшой пример упрощает задачу. Допустим, заказ может быть одобрен, отклонён или отправлен на ревью на основе суммы, статуса клиента и внутренней политики. Это — доменное решение. Отправка письма после одобрения — не доменное. Снятие денег с карты — не доменное. Запись аудита — не доменное. Эти действия происходят вокруг решения, но не внутри него.
Чек‑лист также помогает, когда код аккуратен, но ощущается неправильным. Метод сервиса может быть коротким и всё равно держать бизнес‑логику в неверном слое. Если метод по сути говорит «загрузить данные, попросить домен решить, сохранить результат, затем уведомить другие системы», раздел обычно чистый.
Если вы не можете чётко ответить на эти вопросы — не коммитьте. Переименуйте метод, уберите побочные эффекты и посмотрите, какое правило останется.
Что делать дальше в кодовой базе
Начните с одного потока, который уже вызывает споры. Выберите что‑то маленькое и реальное: утверждение заказа, обработка возврата или приостановка аккаунта. Затем пометьте каждый шаг в этом потоке как решение или координацию. Если шаг отвечает на бизнес‑вопрос — он в домене. Если он загружает данные, сохраняет изменения, вызывает другие системы или отправляет сообщения — он в слое приложения.
Этот простой проход обычно быстро выявляет проблему. Команды часто смешивают оба вида работы в одном методе сервиса, и тогда каждое изменение кажется рискованным.
Перед перемещением чего‑то добавьте тесты вокруг бизнес‑исходов. Тестируйте правило, а не форму метода. Проверьте, что заказ с неоплаченными счетами не может быть одобрен, или что клиент с нужным статусом может получить скидку. Эти тесты защитят вас при переносе логики в доменный сервис или обратно в сущность.
Держите рефактор маленьким. Переносите одно правило за раз. Давайте методам скучные и ясные имена. Оставляйте код координации на месте, пока правило не станет стабильным. Запускайте тесты после каждого шага.
Полный перепис звучит привлекательно, но чаще порождает новую путаницу. Одно хорошее перемещение правила лучше, чем двадцать быстрых переносов. После нескольких небольших рефакторов шаблон становится заметнее. Вы начинаете видеть, какие классы в основном координируют работу, а какие действительно принимают бизнес‑решения.
Используйте тот же вопрос в ревью кода: «Это решает бизнес или координирует систему?» Он делает ревью короче и повышает согласованность дизайна слоёв в команде.
Если команда постоянно застревает на границах, внешнее ревью может помочь. Oleg Sotnikov на oleg.is работает как Fractional CTO и подобная очистка архитектуры — именно та практическая задача, где сфокусированное мнение со стороны может сэкономить недели нервов.