handbookНаписать

patterns/enterprise/service-layer/README.md · тема · 4 языка · 29 тестов

+ практика: регистрация на мероприятие

Сервисный слой (Service Layer) ​

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

Проблема ​

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

Если это делает HTTP-контроллер, то консольная команда для поддержки («перевести вручную») копирует те же четыре шага, а через полгода к ним добавляется фоновая задача. Шаги расходятся: в одном месте забыли сохранить второй счёт, в другом поменяли порядок. Контроллер к тому же нельзя проверить без HTTP: сценарий намертво склеен с разбором запроса и форматом ответа.

Решение ​

Сценарий становится методом сервиса: TransferService.transfer(from, to, amount) загружает счета через репозиторий, отдаёт работу домену и сохраняет результат. Сервис не знает ни про HTTP, ни про аргументы командной строки: на вход простые значения, на выход результат или ошибка.

Обязанности делятся на три уровня:

  • Вход (HttpTransfer, CliTransfer) — разобрать формат своего канала и перевести результат и ошибки обратно в него: статус и JSON или код выхода и текст.
  • Сервис — сценарий: что загрузить, кого вызвать, что сохранить. Здесь же в настоящем проекте граница транзакции и права доступа.
  • Домен (Account) — правила, которые верны при любом входе: «нельзя списать больше, чем есть».

Структура ​

uses

HttpTransfer

+handle(form) : status, body

CliTransfer

+run(args) : code, output

TransferService

+transfer(fromId, toId, amountCents) : int

«interface»

AccountRepository

+get(id) : Account

+save(account)

InMemoryAccountRepository

Account

+id: string

+balance() : int

+withdraw(amountCents)

+deposit(amountCents)

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

Входы ​

Входы делают только перевод: строка формы или аргумент командной строки превращается в число, ошибка сервиса — в статус 404/409 или код выхода 1. Неверную сумму вход отсекает сам, до сервиса: формат ввода — забота канала, а сервис получает уже типизированные значения. Один и тот же отказ домена HTTP показывает как 409 с JSON, а консоль как error: ... с кодом 1: текст ошибки общий, формат у каждого свой.

Как это собирается ​

Всё собирается один раз при старте процесса, в composition root: репозиторий, TransferService поверх него и два входа, которые получают один и тот же сервис. В приложении это контейнер или main; дальше роутер отдаёт POST-запрос HttpTransfer, а консольная команда вызывает CliTransfer. Каждый запрос проходит один путь: вход разбирает форму или аргументы, сервис загружает счета и отдаёт работу Account, вход переводит результат в свой формат.

В примере три HTTP-запроса и одна команда. Первый перевод проходит, второй отсекает сам вход (сумма не число, до сервиса дело не доходит), третий получает 404 от сервиса: счёта zoe нет. Команда пытается перевести больше, чем осталось, и завершается с кодом 1. Последняя строка показывает, что неудачные вызовы балансы не тронули.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Сервис TransferService — один метод на сценарий, и он короткий: два get, вызовы домена, два save. Если в нём появляются if про баланс или лимиты, правило уехало из домена. Возвращает он простое значение (остаток на счёте), а не Account: вход не должен трогать сущность в обход сервиса.

ЯзыкОсобенность
PHPВходы — классы HttpTransfer и CliTransfer (функции не попадают в classmap-автозагрузку); ошибки — классы от DomainException
GoВходы — функции над url.Values и []string; ошибки — сентинелы, вход переводит их в статус через errors.Is
PythonРепозиторий — Protocol, сервис — frozen dataclass; входы — функции, возвращают кортеж (код, текст)
TypeScriptВходы — функции над URLSearchParams; ошибки — подклассы Error, неизвестные пробрасываются дальше

Запуск: make test T=patterns/enterprise/service-layer

Плюсы и минусы ​

Тесты показывают главное: HTTP и консоль работают через один сервис и одно хранилище, поэтому перевод через HTTP виден в консоли. Отказ домена приходит в оба входа, каждый показывает его по-своему, а баланс не меняется. Плохая сумма не доходит до сервиса. И сервис проверяется сам по себе, без входа.

  • ✅ Сценарий записан один раз: новый вход (очередь, gRPC) — это ещё один тонкий переводчик, а не копия шагов.
  • ✅ Сценарий тестируется без HTTP и командной строки: вызвал метод, проверил результат.
  • ✅ Видна граница приложения: всё, что может сделать система, — список публичных методов сервисов.
  • ❌ Легко начать класть в сервис правила предметной области. Тогда домен становится набором полей с геттерами (анемичная модель), а сервисы разрастаются и дублируют проверки.
  • ❌ Лишний слой в простом CRUD: если сценарий — «сохранить форму в таблицу», сервис только пересылает вызов.

Когда не применять ​

  • Вход один и не появится: скрипт, маленькая админка. Сценарий можно оставить в обработчике.
  • Сценарий — чистый CRUD без правил: сервис будет копией репозитория.
  • Нужно упростить вызов нескольких подсистем, а не описать сценарий над доменом: это Фасад.

Отличия между языками ​

  • PHP — классмап-автозагрузка Composer не подхватывает функции, поэтому входы — маленькие классы с одним методом, как контроллер и команда во фреймворке. Сервис final readonly: его состояние — только зависимости.
  • Go — сервис — структура с полем-интерфейсом, без конструктора: нулевых значений хватает. Ошибки домена — сентинелы с контекстом через %w, поэтому текст в Go длиннее (insufficient funds: account ann: ...), а вход сверяет их через errors.Is. Неизвестная ошибка превращается в 500, потому что исключений нет.
  • Python — Protocol даёт сервису контракт репозитория без наследования; json.dumps с separators даёт тот же компактный JSON, что и в других языках.
  • TypeScript — URLSearchParams — стандартное представление формы; get возвращает null, и ?? "" сводит отсутствие поля к пустой строке. Неизвестную ошибку вход пробрасывает: ловить её должен общий обработчик сервера.

Практика ​

Учебный сервис доверяет входу: суммы уже проверены, а сохранение не падает. В жизни сценарий получает команду, которую надо проверить, правила домена отказывают по-разному, сохранение может сорваться на середине, а письмо уходит только после записи. Практика: регистрация на мероприятие показывает DTO на входе и выходе, границу транзакции вокруг сценария и разные типы ошибок для ввода и для правил.

Связанные темы ​

  • Практика: регистрация на мероприятие — команда, транзакция, разделение ошибок и уведомление после коммита
  • Фасад (Facade) — тоже один вход в несколько частей, но упрощает вызов подсистем, а не описывает сценарий приложения над доменом.
  • Команда (Command) — запрос как объект; сервисный метод часто принимает такую команду вместо списка аргументов.
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS