Domain Events: команды и события как ADT
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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,
}
Три части:
initial, начальное состояние агрегата.decide(state, command), решающая функция. Возвращает либо список событий, которые должны быть выпущены, либо ошибку, если команду нельзя применить.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):
- 17. Value Object и Email: opaque + smart-конструктор + parse-don’t-validate.
- 18. Money с phantom-параметром: compile-time гарантия валюты.
- 19. Entity Customer: identity vs equality, record update.
- 20. Aggregate Reservation: FSM состояний и стек инвариантов через
result.try. - 21. Domain Events: команды и события как два ADT.
У тебя в 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.