Практика: регистрация на мероприятие (Event registration)
Регистрация на лекцию с ограниченным числом мест: сервис принимает проверенную команду, держит транзакцию вокруг сценария, отдаёт DTO вместо сущности и шлёт письмо только после записи.
Базовый пример: Сервисный слой. Там вход сам проверяет сумму, домен отказывает одним способом, а сохранение не падает. Здесь ввод проверяет команда, ошибки ввода и отказы правил — разные типы, а сбой записи на середине сценария не должен оставить ни следа: ни места, занятого без регистрации, ни письма о регистрации, которой нет.
Задача
Лекторий проводит одну лекцию в день, мест в зале немного. Регистрируются с сайта, а форма шлёт дату и email. Сначала контроллер сам проверял поля, сам искал лекцию, сам добавлял слушателя и отправлял письмо. Потом появились жалобы: письмо «ждём вас» приходило, а в списке человека не было — база упала после отправки. На пустой email сайт отвечал 500, потому что проверка жила в одном месте формы, а запрос пришёл в обход неё. А мобильное приложение, получив в ответе всю сущность лекции, начало показывать чужие email из списка слушателей.
Всё это — вопросы сценария, а не контроллера и не сущности: какой ввод вообще допустим, где начинается и кончается запись, что отдать наружу и когда можно слать письмо.
Что урезано
- База данных — словарь в памяти с транзакцией через снимок: при ошибке словарь возвращается к снимку. Отказ базы изображает флаг
failCommit, который тест включает вручную. - Почта — список строк в
InMemoryNotifier. Повторной отправки и очереди нет. - Вход один — HTTP-обработчик над формой (
array,url.Values,dict,URLSearchParams). Второй вход (консоль) показан в базовом примере; команда с проверкой здесь как раз для того, чтобы любой новый вход получил ту же проверку бесплатно. - Нет отмены регистрации, листа ожидания, часовых поясов: дата — строка
YYYY-MM-DD.
Решение
Сценарий собран из пяти частей, и у каждой одна обязанность:
- Команда
RegisterCommand— DTO на входе. Создаётся только черезfromInput, который чистит ввод (пробелы, регистр email) и проверяет формат: дата настоящая, email не пустой и с@. Ошибки собираются все сразу вInvalidInputс полемerrors: клиент исправит форму за один заход. - Домен
Event— правила, которые не зависят от формы ввода: повторная регистрация (AlreadyRegistered) и нет мест (EventFull). Все отказы наследуютRegistrationRejected. - Хранилище
InMemoryEvents—get/saveиtransaction(work). - Сервис
RegistrationService— сценарий внутри транзакции и письмо после неё. ВозвращаетRegistrationResult: дата, email и сколько мест осталось. - Вход
HttpRegister— переводит результат и типы ошибок в статусы.
Структура
Домен ничего не знает о форме и хранилище: 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 тоже отказ, поэтому проверяется раньше общего класса.
| Язык | Особенность |
|---|---|
| PHP | InvalidInput от InvalidArgumentException, отказы от DomainException; дата проверяется через createFromFormat('!Y-m-d') и обратное форматирование; clone для копий; json_encode результата берёт публичные свойства |
| Go | *InvalidInput ищется через errors.As, отказы — сентинелы через errors.Is; time.Parse(time.DateOnly); slices.Clone и maps.Clone для копий; JSON-теги на результате |
| Python | RegisterCommand.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 notifies | 2026-11-14, bob@example.com | 201, мест 0, одно письмо |
| bad input never reaches the service | 2026-02-30, пробел | 400, мест 1, писем нет |
| second registration is rejected | 2026-11-14, ANN@example.com | 409: команда привела email к ann@example.com, и домен узнал повтор |
| full event is rejected | 2026-11-21, bob@example.com | 409 |
| unknown event is not found | 2026-12-01, bob@example.com | 404 |
| failed commit saves nothing | 2026-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(), потому что приватный массив снаружи не скопировать.
Связанные темы
- Сервисный слой (базовый пример)
- Фасад (Facade) — тоже один вход в несколько частей, но без правил предметной области и транзакции; сравнение — в разделе «Где граница».
- Команда (Command) — запрос как объект;
RegisterCommand— её облегчённый вариант: данные без методаexecute.