handbookНаписать

patterns/enterprise/service-layer/practice/README.md · практика · 4 языка · 39 тестов

база: Сервисный слой

Практика: регистрация на мероприятие (Event registration) ​

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

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

Задача ​

Лекторий проводит одну лекцию в день, мест в зале немного. Регистрируются с сайта, а форма шлёт дату и email. Сначала контроллер сам проверял поля, сам искал лекцию, сам добавлял слушателя и отправлял письмо. Потом появились жалобы: письмо «ждём вас» приходило, а в списке человека не было — база упала после отправки. На пустой email сайт отвечал 500, потому что проверка жила в одном месте формы, а запрос пришёл в обход неё. А мобильное приложение, получив в ответе всю сущность лекции, начало показывать чужие email из списка слушателей.

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

Что урезано ​

  • База данных — словарь в памяти с транзакцией через снимок: при ошибке словарь возвращается к снимку. Отказ базы изображает флаг failCommit, который тест включает вручную.
  • Почта — список строк в InMemoryNotifier. Повторной отправки и очереди нет.
  • Вход один — HTTP-обработчик над формой (array, url.Values, dict, URLSearchParams). Второй вход (консоль) показан в базовом примере; команда с проверкой здесь как раз для того, чтобы любой новый вход получил ту же проверку бесплатно.
  • Нет отмены регистрации, листа ожидания, часовых поясов: дата — строка YYYY-MM-DD.

Решение ​

Сценарий собран из пяти частей, и у каждой одна обязанность:

  1. Команда RegisterCommand — DTO на входе. Создаётся только через fromInput, который чистит ввод (пробелы, регистр email) и проверяет формат: дата настоящая, email не пустой и с @. Ошибки собираются все сразу в InvalidInput с полем errors: клиент исправит форму за один заход.
  2. Домен Event — правила, которые не зависят от формы ввода: повторная регистрация (AlreadyRegistered) и нет мест (EventFull). Все отказы наследуют RegistrationRejected.
  3. Хранилище InMemoryEvents — get/save и transaction(work).
  4. Сервис RegistrationService — сценарий внутри транзакции и письмо после неё. Возвращает RegistrationResult: дата, email и сколько мест осталось.
  5. Вход HttpRegister — переводит результат и типы ошибок в статусы.

Структура ​

builds

throws

returns

copies

throws

HttpRegister

+handle(form) : status, body

RegisterCommand

+date: string

+email: string

+fromInput(date, email) : RegisterCommand

InvalidInput

+errors: field -> message

RegistrationService

+register(RegisterCommand) : RegistrationResult

RegistrationResult

+date: string

+email: string

+seatsLeft: int

InMemoryEvents

+get(date) : Event

+save(Event)

+transaction(work) : T

Event

+date: string

+capacity: int

+register(email)

+seatsLeft() : int

RegistrationRejected

«interface»

Notifier

+registered(RegistrationResult)

Домен ничего не знает о форме и хранилище: Event получает уже чистый email и сам решает, можно ли его добавить. Проверка повтора идёт раньше проверки мест: человек, который уже записан на полную лекцию, должен услышать «вы уже записаны», а не «мест нет». EventNotFound тоже отказ по правилам, а не ошибка ввода: дата корректна, просто лекции в этот день нет.

Транзакция ​

InMemoryEvents показывает две вещи, на которых держится граница транзакции в настоящей базе.

  • Копии. get отдаёт копию сущности, save кладёт копию. Пока сценарий не сохранил изменения, хранилище их не видит. Без копий event.register() поменял бы запись прямо в словаре, и откатывать было бы нечего.
  • Снимок. transaction(work) запоминает строки, выполняет сценарий и при любой ошибке, в том числе при сбое коммита после save, возвращает строки к снимку и пробрасывает ошибку дальше.

RegistrationService решает, что входит в транзакцию: загрузка, правило, сохранение и сборка результата. Письмо — снаружи: Notifier вызывается только после того, как transaction вернулась без ошибки. Иначе получится то самое «письмо пришло, а записи нет». Наружу уходит RegistrationResult, а не Event: список слушателей остаётся внутри.

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

При старте процесса composition root создаёт хранилище с двумя лекциями, InMemoryNotifier, RegistrationService поверх них и HttpRegister, которому роутер отдаёт каждый POST формы. Дальше запросы идут один за другим через одни и те же объекты, и видно, как меняется состояние: bob занимает последнее место на 14 ноября, его повторная запись с другим регистром и пробелом получает 409, форма с несуществующей датой и пустым email - 400 сразу по двум полям.

Четвёртый запрос приходит, когда база недоступна: failCommit срывает коммит, транзакция откатывается, обработчик отвечает 500, и письма нет. Тот же запрос через секунду проходит. В конце два письма, ровно по числу успешных записей: Notifier вызывается только после того, как транзакция вернулась без ошибки. Клиент читает ответ так, как читал бы фронтенд: по статусу и JSON.

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

Реализации ​

Вход — единственное место, где появляются HTTP-статусы. Он строит команду и вызывает сервис, а потом разбирает ошибки по типу: InvalidInput -> 400 со списком полей, EventNotFound -> 404, любой другой RegistrationRejected -> 409 с текстом правила, всё остальное -> 500 без подробностей. Порядок важен: EventNotFound тоже отказ, поэтому проверяется раньше общего класса.

ЯзыкОсобенность
PHPInvalidInput от InvalidArgumentException, отказы от DomainException; дата проверяется через createFromFormat('!Y-m-d') и обратное форматирование; clone для копий; json_encode результата берёт публичные свойства
Go*InvalidInput ищется через errors.As, отказы — сентинелы через errors.Is; time.Parse(time.DateOnly); slices.Clone и maps.Clone для копий; JSON-теги на результате
PythonRegisterCommand.from_input — classmethod frozen dataclass; copy.deepcopy для копий; transaction[T] — обобщённый метод; вход собирает JSON с ключом seatsLeft сам
TypeScriptЗакрытый конструктор команды; дата проверяется через Date и toISOString; Event.copy() копирует массив слушателей; результат — объектный литерал под интерфейсом

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

Что проверяют тесты ​

Тесты одинаковы во всех языках. Две лекции: 14 ноября на 2 места и 21 ноября на 1 место, на обе уже записана ann@example.com. Каждый случай идёт через HTTP-вход, после него проверяется статус, сколько мест осталось 14 ноября и сколько писем ушло.

СлучайДата, emailИтог
registers and notifies2026-11-14, bob@example.com201, мест 0, одно письмо
bad input never reaches the service2026-02-30, пробел400, мест 1, писем нет
second registration is rejected2026-11-14, ANN@example.com409: команда привела email к ann@example.com, и домен узнал повтор
full event is rejected2026-11-21, bob@example.com409
unknown event is not found2026-12-01, bob@example.com404
failed commit saves nothing2026-11-14, bob@example.com, сбой коммита500, мест по-прежнему 1, писем нет: запись откатилась, письмо не ушло

Отдельные тесты проверяют тела ответов: успешный ответ — ровно {"date":"2026-11-14","email":"bob@example.com","seatsLeft":0}, без списка слушателей; ошибка ввода перечисляет оба поля сразу; отказ правила называет дату (no seats left for 2026-11-21).

Где граница ​

  • Анемичная модель. Самая частая беда сервисного слоя: проверки «уже записан» и «мест нет» переезжают в RegistrationService, а Event остаётся набором полей с геттерами. Пока сценарий один, разницы не видно. Когда появляется второй (перенос регистрации на другую дату, запись группы), правила копируются в каждый сервис и расходятся. Признак: в сервисе if про данные сущности. Сервис должен говорить «что сделать», а «можно ли» — домен.
  • Сервис против Фасада. Внешне похожи: один метод вместо нескольких вызовов. Но Фасад упрощает вход в набор подсистем и ничего не знает о правилах предметной области, а сервис — это граница приложения: на нём живут транзакция, права и перевод ошибок домена в результат сценария. Сервис может использовать фасад над внешними системами (почта, платежи), но фасад не заменяет сервис.
  • Транзакция и внешние эффекты. Письмо после коммита — честное решение, пока потеря письма допустима. Если письмо обязательно, его кладут в outbox в той же транзакции, а отправляет отдельный процесс. Если же сбой может случиться между коммитом и отправкой, а дубли недопустимы, нужна идемпотентность на стороне получателя.
  • Гонка за последнее место. Снимок в памяти не защищает от двух одновременных запросов. В настоящей базе нужна блокировка строки лекции (SELECT ... FOR UPDATE) или версия записи с повтором сценария при конфликте.
  • Когда избыточно. Форма записи без правил (подписка на рассылку) — это сохранение строки. Команда, транзакция и разделение ошибок там превращаются в церемонию.

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

  • PHP — исключения разных веток SPL (InvalidArgumentException и DomainException) сами говорят, чья это ошибка, и вход ловит их отдельными catch. createFromFormat без обратного форматирования пропустил бы 2026-02-30 как 2 марта. clone копирует массив слушателей, потому что массивы в PHP — значения.
  • Go — исключений нет, поэтому разделение ошибок держится на типах: ошибка ввода — структура *InvalidInput с полями, отказы — сентинелы с контекстом через %w. Срез при копировании структуры остаётся общим: без slices.Clone в Get и Save копия и строка хранилища делят один массив, и append при свободной ёмкости допишет слушателя мимо транзакции. Транзакция принимает func() error, а результат сценария выносится в переменную замыкания.
  • Python — copy.deepcopy копирует dataclass вместе со списком. datetime.strptime принимает 2026-1-5, поэтому дата сверяется обратным форматированием. Имена полей в JSON (seatsLeft) задаёт вход, а не dataclass: формат ответа — дело канала.
  • TypeScript — catch получает unknown, и разбор по instanceof идёт от частного к общему. new Date("2026-02-30") молча даёт 2 марта, поэтому дата сверяется с toISOString. Копия сущности — метод copy(), потому что приватный массив снаружи не скопировать.

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

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS