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

Domain Events: команды и события как ADT

middle-senior~40 мин

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

Domain Events: команды и события как ADT

Пятая и финальная ката первого блока. Берём наши переходы из урока 20 и разворачиваем их в две ADT: команды и события. На первый взгляд избыточно, но это самый важный шаг перед Decider, ES и CQRS.

Сцена · разные времена глаголов

Менеджер ресепшен говорит командами в настоящем: “забронируй 101”, “оформи заезд Иванова”, “отмени бронь Сидорова”. Бухгалтерия в конце дня говорит событиями в прошедшем: “забронировано пять номеров”, “оформлено три заезда”, “отменена одна бронь”.

Это не одно и то же. Команды можно отвергнуть (101 уже занят), события нет (что было, то было; компенсировать можно, отменить, нельзя). UI и API работают в командах. БД, аудит, отчёты, проекции работают в событиях. Decider Шассена это функция между ними: принимает (state, command), возвращает список событий или ошибку.

В этой кате мы только закладываем словарь. Решающую функцию decide напишем в уроке 28, когда серия дойдёт до Decider формально.

Цели каты

  • Понять, чем команда отличается от события и зачем разводить их по разным ADT.
  • Объявить Command и Event для нашего домена.
  • Узнать про occurred_at как обязательное поле любого события.
  • Подготовить почву для Decider в уроке 28: уметь говорить “команда -> события” языком типов.

Концепт · команды и события у Шассена

Жереми Шассен в статье 2021 года формализовал паттерн Decider:

type Decider<Command, State, Event, Error> = {
  initial: State,
  decide: (state: State, command: Command) -> Result(List(Event), Error),
  evolve: (state: State, event: Event) -> State,
}

Три части:

  1. initial, начальное состояние агрегата.
  2. decide(state, command), решающая функция. Возвращает либо список событий, которые должны быть выпущены, либо ошибку, если команду нельзя применить.
  3. evolve(state, event), накатывание события на состояние. Чистая, детерминированная, без I/O.

decide это бизнес-решение: можно или нельзя, и какие факты записать. evolve это техническая редукция: применить факт, получить новое состояние. Пара даёт замкнутый цикл: команда приходит, decide эмитит события, evolve обновляет state, дальше следующая команда приходит на новый state.

В 20-aggregate-reservation · Aggregate Reservation мы написали place, cancel, check_in, check_out. Это уже наполовину decide: state-машина переходов с отказами. Не хватает только разделения “ответ это новый state vs список фактов”.

В этой кате мы делаем декларативный шаг: объявляем Command и Event как два отдельных ADT, чтобы в уроке 28 переписать place, cancel, … в форму, возвращающую Result(List(Event), DomainError).

Концепт · почему два ADT, а не один

Соблазн положить всё в одну ADT: “PlaceCommand, OrderPlacedEvent, CancelCommand, OrderCancelledEvent”. Это плохая идея, потому что у двух категорий разная семантика:

  • Команды отвергаются. На команду decide может вернуть Error. Это не баг, это нормально.
  • События нет. В стриме событий не существует “ошибки”. Что записано, то факт.
  • Команды живут на границе. Они приходят из UI, API, CLI. Они должны быть Schema-валидируемы, версионируемы, потенциально подвергаться idempotency-проверке.
  • События живут вечно. Они хранятся в event store, replay-ятся через годы, сериализуются один раз и десериализуются миллион. Их форму менять опасно.

Развести их в две ADT, значит явно разграничить два мира на уровне типов. Любая функция-обработчик принимает одно, возвращает другое. Никакой случайной “вернул команду как событие”.

Языковые механики Gleam · pattern matching на ADT

Размеченное объединение в Gleam, это ADT с exhaustive pattern matching. Когда мы добавим новый вариант (например, PaymentRefunded), компилятор обойдёт все case по Event и потребует обработать новый случай. Это главная причина, почему DDD на Gleam ощущается как “сам себя ведёт по правильной дороге”.

case event {
  ReservationPlaced(_, _, _, _, _) -> "ReservationPlaced"
  ReservationCancelled(_, _) -> "ReservationCancelled"
  GuestCheckedIn(_, _) -> "GuestCheckedIn"
  GuestCheckedOut(_, _) -> "GuestCheckedOut"
  // Добавим новый Event, тут будет ошибка компиляции до тех пор,
  // пока не обработаем новый вариант.
}

Задача · сигнатуры

Открой examples/ddd-hotel/gleam/src/domain/events.gleam:

import domain/customer.{type CustomerId}
import domain/reservation.{type DateRange, type ReservationId, type RoomNumber}

pub type Command {
  PlaceReservation(id, guest, room, range)
  CancelReservation(id)
  CheckInGuest(id)
  CheckOutGuest(id)
}

pub type Event {
  ReservationPlaced(id, guest, room, range, occurred_at: Int)
  ReservationCancelled(id, occurred_at: Int)
  GuestCheckedIn(id, occurred_at: Int)
  GuestCheckedOut(id, occurred_at: Int)
}

pub fn command_name(command: Command) -> String
pub fn event_name(event: Event) -> String

Контракты:

  • Команды не несут occurred_at. Команда это запрос, время команды не важно для бизнеса (важно “решено ли” и “когда решено”). occurred_at зашиваем в событие.
  • События обязаны иметь occurred_at: Int (UTC epoch ms). Без этого аудит не работает, проекции не работают, replay недетерминирован.
  • command_name и event_name это утилиты для логов и для error-сообщений (вспомни InvalidStateTransition из урока 20, поле command: command_name(cmd)).

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

Подсказки

  • Command и Event это отдельные типы. Не наследуй, не унифицируй через common-поле. Они разные.
  • Не клади бизнес-логику в command_name/event_name. Это только строки для диагностики.
  • Поля id и тип ReservationId импортируются из domain/reservation. Это нормальная зависимость: события всегда привязаны к агрегату.

Разбор · полное решение

// examples/ddd-hotel/gleam/src/domain/events.gleam

import domain/customer.{type CustomerId}
import domain/reservation.{
  type DateRange, type ReservationId, type RoomNumber,
}

pub type Command {
  PlaceReservation(
    id: ReservationId,
    guest: CustomerId,
    room: RoomNumber,
    range: DateRange,
  )
  CancelReservation(id: ReservationId)
  CheckInGuest(id: ReservationId)
  CheckOutGuest(id: ReservationId)
}

pub type Event {
  ReservationPlaced(
    id: ReservationId,
    guest: CustomerId,
    room: RoomNumber,
    range: DateRange,
    occurred_at: Int,
  )
  ReservationCancelled(id: ReservationId, occurred_at: Int)
  GuestCheckedIn(id: ReservationId, occurred_at: Int)
  GuestCheckedOut(id: ReservationId, occurred_at: Int)
}

pub fn command_name(command: Command) -> String {
  case command {
    PlaceReservation(_, _, _, _) -> "PlaceReservation"
    CancelReservation(_) -> "CancelReservation"
    CheckInGuest(_) -> "CheckInGuest"
    CheckOutGuest(_) -> "CheckOutGuest"
  }
}

pub fn event_name(event: Event) -> String {
  case event {
    ReservationPlaced(_, _, _, _, _) -> "ReservationPlaced"
    ReservationCancelled(_, _) -> "ReservationCancelled"
    GuestCheckedIn(_, _) -> "GuestCheckedIn"
    GuestCheckedOut(_, _) -> "GuestCheckedOut"
  }
}

Меньше пятидесяти строк. Это словарь, не алгоритм. Алгоритмическая часть приедет в уроке 28 (Decider), где появятся decide(state, command) и evolve(state, event).

Тесты

// examples/ddd-hotel/gleam/test/events_test.gleam

import gleeunit/should

import domain/customer
import domain/events
import domain/reservation

pub fn command_name_test() {
  let assert Ok(id) = reservation.reservation_id("res_abcd1234")

  events.CancelReservation(id)
  |> events.command_name()
  |> should.equal("CancelReservation")
}

pub fn event_name_test() {
  let assert Ok(id) = reservation.reservation_id("res_abcd1234")

  events.GuestCheckedIn(id, occurred_at: 1_700_000_000_000)
  |> events.event_name()
  |> should.equal("GuestCheckedIn")
}

pub fn placed_event_has_occurred_at_test() {
  let assert Ok(id) = reservation.reservation_id("res_abcd1234")
  let assert Ok(guest) = customer.customer_id("cust_001")
  let assert Ok(room) = reservation.room_number("101")
  let assert Ok(range) = reservation.date_range(20_260_101, 20_260_103)

  let occurred_at = 1_700_000_000_000
  let event =
    events.ReservationPlaced(id, guest, room, range, occurred_at: occurred_at)

  case event {
    events.ReservationPlaced(_, _, _, _, at) -> at
    events.ReservationCancelled(_, at) -> at
    events.GuestCheckedIn(_, at) -> at
    events.GuestCheckedOut(_, at) -> at
  }
  |> should.equal(occurred_at)
}

Заметь, что в placed_event_has_occurred_at_test мы экстрактим occurred_at через exhaustive case. Все четыре варианта обработаны. Если завтра добавишь RoomChanged(id, from_room, to_room, occurred_at), компилятор подсветит этот case, и ты допишешь пятую ветку.

Концепт · команды и события на event modeling доске

В уроке 16 уже была карта серии. В уроке 27 у нас будет полноценный event modeling воркшоп. Здесь, мини-превью:

┌─────────────────────────┐    ┌─────────────────────────────┐    ┌─────────────────────────┐
│  UI · "Забронировать"   │ ─► │   PlaceReservation command  │ ─► │  ReservationPlaced fact │
└─────────────────────────┘    └─────────────────────────────┘    └─────────────────────────┘
                                          │                                    │
                                          ▼                                    ▼
                              ┌────────────────────────┐         ┌──────────────────────────┐
                              │  decide(state, cmd)    │         │  evolve(state, evt)      │
                              │  возвращает событие    │         │  возвращает новый state  │
                              │  или ошибку            │         │                          │
                              └────────────────────────┘         └──────────────────────────┘

Это четыре столбца Event Modeling по Адаму Дымитруку: UI, Command, Event, Read Model (последний за рамками этого урока). В уроке 28 мы превратим это в код Decider целиком.

Критика · что не покрыто

  • Сериализация. События должны храниться в БД, и формат сериализации (JSON, CBOR, Avro) важен. Сейчас мы их только описали как типы Gleam. JSON-форму домена соберём в уроке 25 (SQLite-репозиторий, encode/decode payload), сериализацию стрима событий, в уроке 29 (Event Sourcing).
  • Versioning. События живут вечно, и сменить форму события задним числом нельзя. Стратегии: optional-поля, версионный union, upcasting. Тоже урок 29.
  • Идемпотентность. Если клиент дважды пришлёт одну и ту же команду (retry), мы не хотим записывать два события. Решается полем idempotency_key в команде и проверкой на стороне Decider/Event Store. Урок 29.
  • Cross-aggregate события. “Бронь оформлена, отправить welcome-email” это про другую границу. Появится в уроке 31 (Process Manager и Saga).

Сравнение с Effect-треком

В Effect (examples/ddd-hotel/effect/src/domain/{commands,events}.ts) мы описывали то же самое через Schema.TaggedStruct и Schema.Union. Размер кода схожий, но:

  • В Effect мы сразу получаем Schema-парсер для команды (Schema.decodeUnknown(PlaceReservation)). Это полезно на HTTP-границе. В Gleam парсер придётся писать отдельно (gleam/json либа), encode/decode домена в JSON разберём в уроке 25.
  • В Effect Schema.Union автоматически даёт round-trip JSON ↔ домен. В Gleam пишем encode/decode вручную (или через gleam_json).
  • Pattern matching одинаково изящен в обоих: Match.value(event).pipe(Match.tag(...)) в Effect, case event { ... } в Gleam.

Сильная сторона Gleam: exhaustive по-умолчанию, без Match.exhaustive-маркера. Сильная сторона Effect: Schema из коробки.

Takeaway

Одна фраза:

Команда и событие, два разных языка. Decider это переводчик между ними. Этот урок закладывает словарь, урок 28 пишет переводчик.

ДЗ

Финал релиза R1

Этот урок закрывает первый релиз Gleam-трека функционального DDD. По карте серии (урок 16):

У тебя в examples/ddd-hotel/gleam/ лежит полное доменное ядро: семь модулей, ~25 тестов, всё компилируется и зелёное за пол-секунды. Это фундамент, на котором поедет следующий релиз.

Дальше

Следующая ката · 22. Repository как порт, OrderRepo и in-memory backend. Появляется первая инфраструктурная зависимость: где хранить агрегат. Делаем Repository как тип-запись с функциями load и save, in-memory backend для тестов. Это первый шаг к Hexagonal-архитектуре, и пол-шаги к Event Store, который придёт в уроке 29.

Релиз R2 (уроки 22-26) запланирован на 2026-07 по docs/FUNCTIONAL_DDD_PLAN.md. Сейчас отдыхай и пишет ДЗ.

Параллельно полезно перечитать:

  • 16-fp-ddd-intro · Functional DDD intro, раздел “Команды и события это два разных языка”. Сегодня мы воплотили это в коде.
  • Статью Шассена про Decider (ссылка в resources). Половину её ты уже видел “в действии” в уроке 20, вторую половину разберём в уроке 28.