Раздел 27 · Gleam на практике

Functional DDD на Gleam: с чего начинаем

middle~25 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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). Десять кат, в которых рождается словарь:

  1. Value Object и Email через opaque-тип со smart-конструктором.
  2. Money с валютой как phantom-параметром.
  3. Entity и Customer, идентичность против равенства по содержимому.
  4. Aggregate и Order, стек инвариантов.
  5. Domain Events, команды и события как разные типы.
  6. Repository как порт, in-memory backend для тестов.
  7. Bounded Context, разделение модулей и словаря.
  8. Composition root на Wisp, где собирается граф зависимостей.
  9. SQLite-репозиторий через sqlight.
  10. Конфиг и wiring, фабрика build_repo.

К 26 уроку у тебя на руках работающий веб-сервис: HTTP-роутер, доменная логика без db.query, два backend-а под один порт, и тесты, которые запускаются без БД.

Блок 2, Event Sourcing и Decider (уроки 27-33). Семь уроков, в которых тактическое DDD сменяется на event-first моделирование:

  1. Event Modeling, воркшоп Адама Дымитрука, свимлейн на доске.
  2. Decider Жереми Шассена, универсальная единица домена.
  3. Event Sourcing, стрим событий, evolve, snapshot, optimistic concurrency.
  4. CQRS, разделение write- и read-сторон, проекции.
  5. Process Manager и Saga, charge-on-checkout через границу контекстов.
  6. Aggregateless ES, единый стрим, Facts вместо агрегатов.
  7. Кейс Decider, та же форма на домене Uno, BDD-сценарии.

После Decider “класс с инвариантами” перестаёт быть базовой моделью. Decider универсальнее: он не требует объекта, не требует инкапсуляции, не требует БД. Он требует три функции и тип состояния.

Концепт: что такое DDD

DDD это не “паттерны” и не “архитектура”. Это две вещи:

  1. Дисциплина словаря. В команде и в коде слово “клиент” значит ровно одно. Если у бухгалтерии “клиент” это контрагент со счётом, а у ресепшна “клиент” это гость с историей пребываний, в системе живут два разных типа. Этот словарь зовётся ubiquitous language.

  2. Дисциплина границ. Большая система состоит из 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

Серия опирается на то, что мы уже прошли в первой половине курса, плюс одна-две новых библиотеки.

Новые библиотеки появятся по ходу: sqlight для SQLite-репозитория, envoy для конфига, и в финальном блоке, либо eventsourcing (renatillas/eventsourcing), либо ручная Postgres-реализация (по решению автора курса в R3).

Задача, ubiquitous language для собственного домена

Хочется ката, но вводный урок это не ката, а разминка. Цель, прокачать словарь до того, как мы возьмём конкретный домен в 17 уроке.

Возьми небольшую систему, которую ты знаешь по жизни (бот, тайм-трекер, заметки, твой pet-проект). Выпиши:

  1. Десять слов домена со значением одной фразой каждое. Если для одного слова в разных частях системы значение отличается, это сигнал bounded context.

  2. Пять команд в настоящем времени, по форме <глагол> <сущность>. Например, ScheduleMeeting, MarkTaskDone, RevokeInvite.

  3. Пять событий в прошедшем, парно к командам. По форме <сущность> <глагол в прошедшем>. Например, MeetingScheduled, TaskMarkedDone, InviteRevoked.

  4. Один сценарий, для которого нужна 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-конструктор, валидация на границе. После неё ты увидишь, почему “невозможные состояния невыразимы” это не лозунг, а конкретный приём проектирования.

домашка

Домашка