Functional DDD на Gleam: с чего начинаем
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Functional DDD на Gleam: с чего начинаем
Открываем вторую часть Gleam-серии. Восемнадцать уроков о том, как проектировать домен типами, говорить с бизнесом одним языком и собирать систему из Decider-ов, а не из классов. Этот урок задаёт словарь и маршрут, кода почти нет.
Сцена, две книги встречаются
В 2003 Эрик Эванс выпустил Domain-Driven Design. Из неё пошёл словарь, который ты слышишь каждый день: bounded context, ubiquitous language, aggregate, repository. В 2018 Скотт Влашин выпустил Domain Modeling Made Functional. Та же дисциплина, но на F-sharp: типы как спецификация, smart-конструкторы вместо приватных полей, ошибки как часть сигнатуры.
Эти две книги обычно подают по отдельности. Менторы из OOP-лагеря пересказывают Эванса в терминах классов и интерфейсов. Менторы из FP-лагеря показывают Влашина и пропускают половину стратегического DDD. Курсы по event sourcing уходят в инфраструктуру и забывают, что у домена в принципе есть язык.
Эта серия склеивает оба слоя на одном языке (Gleam) и одном домене (бронирование отеля плюс игра Uno как мини-пример). К концу серии ты умеешь:
- читать ТЗ и переводить его в типы за час, а не за неделю;
- объяснять разницу между командой и событием за тридцать секунд;
- собирать домен из чистых функций так, чтобы поменять SQLite на Postgres за час;
- спорить с командой не “правильно ли у нас Repository”, а “правильно ли мы описали bounded context”.
Карта серии
Восемнадцать уроков, два больших блока. Первая половина это ката-серия по образцу escherize.com/gleam-katas/. Вторая половина это Decider, Event Sourcing, CQRS и saga.
Блок 1, тактический DDD (уроки 17-26). Десять кат, в которых рождается словарь:
- Value Object и Email через opaque-тип со smart-конструктором.
- Money с валютой как phantom-параметром.
- Entity и Customer, идентичность против равенства по содержимому.
- Aggregate и Order, стек инвариантов.
- Domain Events, команды и события как разные типы.
- Repository как порт, in-memory backend для тестов.
- Bounded Context, разделение модулей и словаря.
- Composition root на Wisp, где собирается граф зависимостей.
- SQLite-репозиторий через
sqlight. - Конфиг и wiring, фабрика
build_repo.
К 26 уроку у тебя на руках работающий веб-сервис: HTTP-роутер, доменная логика без db.query, два backend-а под один порт, и тесты, которые запускаются без БД.
Блок 2, Event Sourcing и Decider (уроки 27-33). Семь уроков, в которых тактическое DDD сменяется на event-first моделирование:
- Event Modeling, воркшоп Адама Дымитрука, свимлейн на доске.
- Decider Жереми Шассена, универсальная единица домена.
- Event Sourcing, стрим событий,
evolve, snapshot, optimistic concurrency. - CQRS, разделение write- и read-сторон, проекции.
- Process Manager и Saga, charge-on-checkout через границу контекстов.
- Aggregateless ES, единый стрим, Facts вместо агрегатов.
- Кейс Decider, та же форма на домене Uno, BDD-сценарии.
После Decider “класс с инвариантами” перестаёт быть базовой моделью. Decider универсальнее: он не требует объекта, не требует инкапсуляции, не требует БД. Он требует три функции и тип состояния.
Концепт: что такое DDD
DDD это не “паттерны” и не “архитектура”. Это две вещи:
-
Дисциплина словаря. В команде и в коде слово “клиент” значит ровно одно. Если у бухгалтерии “клиент” это контрагент со счётом, а у ресепшна “клиент” это гость с историей пребываний, в системе живут два разных типа. Этот словарь зовётся ubiquitous language.
-
Дисциплина границ. Большая система состоит из bounded context-ов. Внутри каждого свой язык и своя модель. Между ними явные мосты: события, API, схемы. Никакой context не лезет в чужой код через “shared kernel” больше необходимого.
Всё остальное в DDD это техники, которые поддерживают эти две дисциплины. Aggregate охраняет инварианты. Repository прячет хранение. Domain Event фиксирует факт. Когда видишь термин из DDD, спрашивай не “как это устроено в коде”, а “какую дисциплину словаря или границы он поддерживает”.
ФП-вариант DDD у Влашина переписывает технику, но не дисциплину. Aggregate в Gleam это не класс с приватными полями и публичными методами, а тип со smart-конструктором и набором pure-функций. Repository это запись с полями-функциями load и save, а не интерфейс с тремя реализациями. Domain Event это вариант ADT, а не объект с @Entity. Дисциплина одна: невалидные состояния не должны выражаться в типе.
North Star, три тезиса серии
Если закроешь курс и забудешь 90 процентов, три утверждения должны остаться рефлексом.
1. Невозможные состояния невыразимы
Любая строчка вида if order.status == "closed" && order.paid_amount == null это сигнал: тип спроектирован неверно. “Закрытый заказ без суммы оплаты” просто не должен собираться. В Gleam это даёт ADT с конструкторами под каждое состояние:
pub type Order {
Draft(items: List(Item))
Placed(items: List(Item), placed_at: Timestamp)
Paid(items: List(Item), placed_at: Timestamp, paid: Money)
Cancelled(items: List(Item), reason: String)
}
Здесь нет “оплаченного черновика”. Pattern match по Order обязан разобрать четыре варианта, не больше и не меньше. Если в новом UC появилось пятое состояние, компилятор сообщит во всех местах, где case был неполным.
Это и есть основной приём ФП-DDD: рисуем тип так, чтобы валидное состояние было единственно представимо.
2. Команды и события это два разных языка
Команда в настоящем времени: PlaceOrder, CheckIn, CancelReservation. Это запрос на изменение, который может быть отвергнут. Событие в прошедшем: OrderPlaced, CheckedIn, ReservationCancelled. Это факт, который уже случился, никто его не отменит.
Разница не косметическая. UI и API живут в командах: пользователь нажимает кнопку, на сервер уходит запрос на изменение. Бизнес живёт в событиях: отчёты, аудит, метрики, интеграция с другими контекстами читают факты, не команды. Между ними сидит доменная функция, которая берёт команду плюс текущее состояние и возвращает список событий или ошибку:
pub fn place_order(
cmd: PlaceOrder,
state: Order,
) -> Result(List(Event), OrderError)
Эта сигнатура повторяется во всём блоке 2. Внутри неё ноль побочных эффектов: ни БД, ни сети, ни таймера.
3. Decider универсальнее агрегата
Decider Жереми Шассена это запись из трёх частей:
pub type Decider(command, state, event, error) {
Decider(
initial_state: state,
decide: fn(command, state) -> Result(List(event), error),
evolve: fn(state, event) -> state,
)
}
initial_state это начальное значение. decide принимает команду и состояние, возвращает события или ошибку. evolve обновляет состояние новым событием. Это всё. Никакой инкапсуляции, никакой записи в БД, никакого static.
Из этих трёх функций собирается:
- классический Aggregate (
stateэтоOrder, команды и события описывают его жизнь); - процесс-менеджер (
stateэто маленький FSM, который ловит события одного контекста и эмитит команды в другой); - автомат игры в Uno (
stateэто рука игрока плюс стопка, команды это ход, события это сыграл/взял/завершил); - политика обработки писем (
stateэто очередь, команды это пришло/ушло, события это отправили/не доставили).
Когда форма Decider становится привычной, “агрегат как класс” перестаёт быть полезной моделью.
Сквозной домен, отель плюс Uno
Сериал нужен реальному домену, иначе каты звучат как упражнения в стиле “ListAndOrderProcessorFactory”. Берём Hotel Booking как в синей книге Wlaschin: три bounded context, две read-модели, одна saga.
+---------------------+ +---------------------+
| Reservations | | Front Desk |
| Decider | | Decider |
| - place reservation | | - check in |
| - cancel | | - check out |
| Read-model | | Read-model |
| - daily occupancy | | - in-house guests |
+---------------------+ +---------------------+
\ /
v v
+---------------------------------+
| Billing |
| Decider, folio |
| - add charge |
| - settle folio |
| Saga, charge-on-checkout |
+---------------------------------+
В первой половине серии мы собираем по одному Decider в каждом из трёх контекстов: бронирование, заселение, выставление счёта. Во второй половине поверх них появляются проекции и saga, которая ловит выезд гостя и инициирует оплату.
Параллельно ведём мини-пример Uno. Это карточная игра: рука игрока, стопка, направление, штрафы. Идеальный FSM для показа того же Decider на другом домене. К 33 уроку ты собираешь одну и ту же форму на Hotel Booking и на Uno и видишь: меняется домен, не меняется паттерн.
Что понадобится из Gleam
Серия опирается на то, что мы уже прошли в первой половине курса, плюс одна-две новых библиотеки.
- ADT и pattern matching (27-gleam/04 · Типы и коллекции и 27-gleam/05 · Рекурсия, свёртки, ошибки). Без них Domain Event и Decider не собрать.
- Opaque-типы и smart-конструкторы (27-gleam/07 · Type safety). Это базовый приём для Value Object.
- Result, Option,
use <- result.try(27-gleam/05 · Рекурсия, свёртки, ошибки). Цепочка проверок без вложенных if. - Wisp (27-gleam/12 · Web с Wisp). Composition root живёт здесь, HTTP-хендлеры остаются тонкими.
- gleeunit (27-gleam/11 · Тестирование). Все Decider тестируются через BDD-сценарии
given, when, then. - OTP-акторы (27-gleam/10 · Процессы и OTP). Когда говорим про event store как актор и про supervisor вокруг read-моделей.
Новые библиотеки появятся по ходу: sqlight для SQLite-репозитория, envoy для конфига, и в финальном блоке, либо eventsourcing (renatillas/eventsourcing), либо ручная Postgres-реализация (по решению автора курса в R3).
Задача, ubiquitous language для собственного домена
Хочется ката, но вводный урок это не ката, а разминка. Цель, прокачать словарь до того, как мы возьмём конкретный домен в 17 уроке.
Возьми небольшую систему, которую ты знаешь по жизни (бот, тайм-трекер, заметки, твой pet-проект). Выпиши:
-
Десять слов домена со значением одной фразой каждое. Если для одного слова в разных частях системы значение отличается, это сигнал bounded context.
-
Пять команд в настоящем времени, по форме
<глагол> <сущность>. Например,ScheduleMeeting,MarkTaskDone,RevokeInvite. -
Пять событий в прошедшем, парно к командам. По форме
<сущность> <глагол в прошедшем>. Например,MeetingScheduled,TaskMarkedDone,InviteRevoked. -
Один сценарий, для которого нужна saga: цепочка событий из двух разных частей системы, в которой одно событие должно инициировать команду в другую. Опиши его свободно одним абзацем.
Не требуется писать код. Требуется завести один markdown-файл и поработать в нём словарём.
Подсказки
-
Сначала глаголы, потом существительные. Если домен пока туманный, начни с “что пользователь делает в системе”. Глаголы дают команды, существительные потом подтянутся.
-
События во множественном числе не пишутся.
OrdersPlacedэто либо batch, либо ошибка. Один заказ, одно событиеOrderPlaced. Если действие массовое, выделиBatchPlacedкак отдельный концепт. -
Если команда называется “Update”, чаще всего это две команды. “UpdateUser” обычно скрывает “ChangeEmail” и “ChangeName”. Дроби, пока имя команды не становится понятным без контекста.
-
Если событие называется “Changed”, раздели на “до” и “после”. Иногда нужно оба, иногда только “после”, иногда событие про конкретное действие (
EmailChangedпротивUserUpdated). Голос события всегда конкретный. -
Один сценарий saga, не больше. На разминке не нужно проектировать всю систему, нужно один раз почувствовать, что значит “событие из A инициирует команду в B”.
Разбор
Покажу свой пример словаря. Домен, мини-Telegram-бот для очереди задач команды (что-то вроде упрощённого Linear):
Слова домена. Task (задача, у каждой есть статус и assignee), Assignee (член команды, у каждого Telegram-username), Queue (упорядоченная очередь задач одной команды), Status (один из pending, in_progress, done, cancelled), Estimate (число минут), Reminder (отметка времени напоминания), Channel (Telegram-чат, к которому привязан Queue), Mentee (роль ученика), Mentor (роль преподавателя), Snooze (отложить задачу на N часов).
Пять команд. CreateTask, AssignTask, StartTask, CompleteTask, SnoozeTask.
Пять событий. TaskCreated, TaskAssigned, TaskStarted, TaskCompleted, TaskSnoozed.
Сценарий saga. Когда TaskStarted пришло из Queue-контекста и прошло больше 25 минут без TaskCompleted, Reminder-контекст должен прислать команду SendReminder в Telegram-bridge. Если по приходу Reminder пользователь нажал “продолжаю”, шлём SnoozeTask обратно в Queue. Если нажал “сделано”, команды нет, ждём TaskCompleted. Saga ловит две частоты: события Queue и таймеры.
Что тут стоит заметить:
-
Команды и события идут парами, но не всегда один-к-одному.
SnoozeTaskэто команда,TaskSnoozedсобытие, ОК. НоTaskCompletedприходит из обычного потока и от saga, источник один, событие одно. -
Слова “Mentor” и “Mentee” висят в словаре, но в командах не появляются. Они описывают роли, а не операции. В типах они станут
Role, не отдельнымMentor/Mentee. -
Сценарий saga ясно перечислил два события и две команды. Если получится длиннее, чем три абзаца, это намёк, что внутри сидит другой Decider, который стоит выделить.
Критика
Что в моём разборе не покрыто и почему:
-
Не привязан к bounded context. В разминке мы намеренно не делим на контексты. Это упражнение на словарь, не на стратегию. На 23 уроке мы вернёмся и разделим то, что получилось, на три кластера.
-
Не различаются user-команды и system-команды.
SendReminderэто команда от saga, не от пользователя. У них одинаковая форма, но разные источники. К 31 уроку (Process Manager) выделим этот зазор явно. -
Не описаны идентификаторы.
TaskId,UserId,QueueId, как они генерируются? Мы их обойдём до 19 урока (Entity), там и сделаем дисциплину явной. -
Сценарий saga предполагает таймер. Откуда такты? В классическом ES это либо отдельный тайм-источник событий (
TimerTickedкаждую минуту), либоProcessс timeout в Erlang. К 31 разберём.
Takeaway
В FP-DDD типы это спецификация домена. Команды и события это разные языки. Decider, три функции, заменяющие “класс с инвариантами”. Эти три тезиса прошьют все следующие уроки.
ДЗ
Время на ДЗ суммарно около двух часов. Все три задания идут перед уроком 17.
-
hw-pick-domain(30 мин), Выбрать свой домен. Сохрани markdown-файл с десятью словами, пятью командами, пятью событиями и сценарием saga. Это станет твоей “контрольной картой”, по которой мы будем сверяться весь курс. -
hw-read-chassaing(40 мин), Прочитать статью Chassaing про Decider. Запиши три вопроса, которые остались непонятными. К 28 уроку их разберём в коде. -
hw-watch-wlaschin(60 мин), Посмотреть Wlaschin NDC 2017. Ищи три тезиса: тип как спецификация, рабочий процесс через типы, ошибки как часть домена. F-sharp повторять не надо, идея общая.
Дальше
В 27-gleam/17 · Value Object и Email открываем первую кату по образцу escherize.com/gleam-katas/. Она про Email: opaque-тип, smart-конструктор, валидация на границе. После неё ты увидишь, почему “невозможные состояния невыразимы” это не лозунг, а конкретный приём проектирования.
домашка