Decider Жереми Шассена: три функции вместо агрегата
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Decider Жереми Шассена: три функции вместо агрегата
Вторая ката второго блока. У нас есть свимлейн (27): команды слева, события справа. Сегодня формализуем переход между ними в единую структуру, которую Жереми Шассен назвал Decider. Три чистые функции, и из них собирается всё: агрегат, FSM, процесс-менеджер.
Сцена · что не так с “агрегатом как классом”
В классическом OOP-DDD агрегат это класс: приватные поля, публичные методы, инварианты внутри. reservation.checkIn() меняет поле status и кидает исключение, если нельзя. Состояние спрятано, мутируется на месте, проверить переход в изоляции трудно.
Мы и так ушли от этого в уроке 20: Reservation это ADT, переходы это чистые функции, возвращающие Result. Но у нас переходы возвращают новое состояние (Result(Reservation, DomainError)), а не события. Между “изменить состояние” и “зафиксировать факт” есть зазор, и Decider его закрывает.
Decider разделяет решение и применение. decide смотрит на состояние и команду и решает, какие факты породить (или отвергает). evolve берёт факт и двигает состояние. Состояние никогда не мутируется напрямую, оно всегда результат свёртки фактов.
Карта урока · что заберёшь через 3 часа
- Поймёшь форму Decider:
initial_state,decide,evolve, и зачем их ровно три. - Соберёшь
ReservationDeciderповерх готового агрегата (урок 20), не дублируя FSM-правила. - Запишешь сценарии урока 27 как BDD-тесты: given события, when команда, then события или отказ.
- Поймёшь закон Decider и почему он делает event sourcing (урок 29) возможным.
Концепт · форма Decider
Шассен формулирует Decider как четыре вещи. Тип состояния, и три значения:
initial_stateэто состояние до первого события. У насNotPlaced.decide(state, command) -> Result(events, error)это решение. Чистая функция: смотрит на текущее состояние и команду, возвращает список новых событий или доменную ошибку. Не меняет ничего, только решает.evolve(state, event) -> stateэто применение. Чистая тотальная функция: берёт состояние и одно событие, возвращает следующее состояние. Никогда не падает.
Параметризуется четырьмя типами: команда, состояние, событие, ошибка. В Gleam это запись с полями-функциями, как порт из урока 22, только теперь поля это сама бизнес-логика:
// examples/ddd-hotel/gleam/src/domain/decider.gleam
pub type Decider(command, state, event, error) {
Decider(
initial_state: state,
decide: fn(state, command) -> Result(List(event), error),
evolve: fn(state, event) -> state,
)
}
Почему это “универсальнее агрегата”: ту же форму натягиваешь на FSM карточной игры, на процесс-менеджер (урок 31), на политику обработки писем. Когда форма Decider стала привычной, “агрегат это класс с инвариантами” перестаёт быть базовой моделью. Базовая модель это три функции.
Языковые механики Gleam · чистый decide и впрыск времени
Загвоздка: событие несёт occurred_at, а откуда чистая функция возьмёт время? Время это эффект, а decide должна остаться чистой, иначе BDD-тест станет недетерминированным.
Решение, которое мы используем: фабрика, замыкающая время. reservation_decider(now) возвращает Decider, в котором now уже зафиксирован:
pub fn reservation_decider(now: Int) -> ReservationDecider {
Decider(
initial_state: reservation.initial(),
decide: fn(state, command) { decide(state, command, now) },
evolve: evolve,
)
}
В проде now приходит из часов в композиционном корне. В тесте передаём константу. decide при этом остаётся чистой: при фиксированном now один вход даёт один выход. Это та же дисциплина, что в эффект-треке прячут за Clock, только руками и явно.
Задача · сигнатуры
Собери domain/decider.gleam:
pub fn reservation_decider(now: Int) -> ReservationDecider
fn decide(state: Reservation, command: Command, now: Int) -> Result(List(Event), DomainError)
pub fn evolve(state: Reservation, event: Event) -> Reservation
pub fn replay(decider, events: List(event)) -> state
Контракт:
decideдля каждой команды проверяет, разрешён ли переход, и либо возвращаетOk([событие]), либоError(InvalidStateTransition(...)). Не дублируй FSM-правила: переиспользуй переходы агрегата (reservation.place,cancel,check_in,check_out) как guard.evolveтотальна: на любой паре(state, event)возвращает состояние, никогда не падает. ДляGuestCheckedInдостаётguest/room/rangeиз текущегоReserved, потому что событие несёт только id.replayэтоlist.fold(events, initial_state, evolve). Одна строка.
Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.
Подсказки
decideдляPlaceReservationзовётreservation.place(state, ...). ЕслиOk, состояние-результат выбрасываем, нам нужен только факт того, что переход допустим, и возвращаемOk([ReservationPlaced(...)]). ЕслиError, пробрасываем его. Так FSM-правила живут в одном месте (агрегат), а decide только переводит “можно” в событие.evolveНЕ должна вызыватьreservation.check_inи подобное: те функции возвращаютResultи проверяют инварианты.evolveприменяет уже случившийся факт, проверять нечего. Поэтому она строит состояние прямыми конструкторами и для невалидной пары возвращаетstateбез изменения.- Различие важно:
decideотвергает невозможное (это про команды от пользователя),evolveдоверяет (это про факты из истории, они уже произошли).
Разбор · decide и evolve
fn decide(
state: Reservation,
command: Command,
now: Int,
) -> Result(List(Event), DomainError) {
case command {
PlaceReservation(id, guest, room, range) ->
case reservation.place(state, id, guest, room, range) {
Ok(_) -> Ok([ReservationPlaced(id, guest, room, range, now)])
Error(error) -> Error(error)
}
CancelReservation(id) ->
case reservation.cancel(state) {
Ok(_) -> Ok([ReservationCancelled(id, now)])
Error(error) -> Error(error)
}
CheckInGuest(id) ->
case reservation.check_in(state) {
Ok(_) -> Ok([GuestCheckedIn(id, now)])
Error(error) -> Error(error)
}
CheckOutGuest(id) ->
case reservation.check_out(state) {
Ok(_) -> Ok([GuestCheckedOut(id, now)])
Error(error) -> Error(error)
}
}
}
Каждая ветка одинакова по форме: спроси у агрегата, можно ли, и если да, выпусти факт. FSM-правила (из какого состояния какой переход) остались в reservation.gleam, не размножились.
evolve зеркальна, но проще, потому что не отвергает:
pub fn evolve(state: Reservation, event: Event) -> Reservation {
case event {
ReservationPlaced(id, guest, room, range, _) ->
Reserved(id, guest, room, range)
ReservationCancelled(id, _) -> Cancelled(id)
GuestCheckedIn(id, _) ->
case state {
Reserved(_, guest, room, range) -> CheckedIn(id, guest, room, range)
_ -> state
}
GuestCheckedOut(id, _) -> CheckedOut(id)
}
}
GuestCheckedIn несёт только id, поэтому guest/room/range берём из текущего Reserved. Если состояние не Reserved (битый стрим), возвращаем state как есть: evolve не судья, она применяет.
Погоняй Decider руками: жми команды и смотри, что вернёт decide и куда уедет evolve:
BDD-тесты · свимлейн стал кодом
Сценарии given/when/then из урока 27 переписываются в тест дословно. Заведём хелпер verify:
// examples/ddd-hotel/gleam/test/decider_test.gleam
fn verify(
given given: List(Event),
when command: Command,
then expected: Result(List(Event), errors.DomainError),
) {
let d = the_decider()
let state = decider.replay(d, given)
d.decide(state, command)
|> should.equal(expected)
}
pub fn check_in_after_place_emits_checked_in_test() {
let assert Ok(id) = reservation.reservation_id("res_decider01")
verify(
given: [placed_event()],
when: CheckInGuest(id),
then: Ok([GuestCheckedIn(id, now)]),
)
}
pub fn cancel_after_check_in_is_rejected_test() {
let assert Ok(id) = reservation.reservation_id("res_decider01")
verify(
given: [placed_event(), GuestCheckedIn(id, now)],
when: CancelReservation(id),
then: Error(InvalidStateTransition(
from: "CheckedIn",
command: "CancelReservation",
)),
)
}
verify сворачивает given в состояние через replay, прогоняет decide, сравнивает с then. Это и есть та доска: given прошлые факты, when команда, then новые факты или отказ. Тест читается как сценарий, потому что он и есть сценарий.
Концепт · закон Decider
У Decider есть закон, который связывает три функции:
Для любого валидного стрима событий
replay(events)равноlist.fold(events, initial_state, evolve).
Звучит тавтологией (replay и есть этот fold), но смысл глубже: состояние полностью определяется историей событий. Нет состояния “сбоку” от стрима. Что бы ни случилось, текущее состояние это свёртка прошлых фактов через evolve, и ничего больше.
Этот закон и делает event sourcing возможным (урок 29): если состояние это свёртка, его не надо хранить, достаточно хранить события и сворачивать их по требованию. decide отвечает “что произойдёт дальше”, evolve отвечает “как история складывается в настоящее”.
Критика · что не покрыто
- decide возвращает список, но мы всегда даём один элемент. Это специально: некоторые команды порождают несколько событий разом. Список это общая форма (ДЗ).
- Нет команд, читающих другие агрегаты.
decideвидит только своё состояние. Если решение требует чужих данных, их прокидывают аргументом команды или это уже процесс-менеджер (урок 31). - Decider пока без стрима. Сегодня он чистый FSM: дай состояние, дай команду, получи события. Где брать события и куда писать, это event store урока 29. Decider от хранилища не зависит, и это правильно.
- Композиция Decider. Два Decider складываются в один (состояние это кортеж). Это и есть “универсальнее агрегата”: формы складываются (ДЗ).
Takeaway
Одна фраза:
Decider это три чистые функции:
initial_state,decide(команда в события или отказ),evolve(событие в состояние).decideотвергает невозможное,evolveдоверяет истории, и состояние всегда равно свёртке фактов.
ДЗ
Дальше
Следующая ката · 29. Event Sourcing. Теперь, когда состояние это свёртка фактов, перестанем хранить состояние и начнём хранить факты. Event store, replay, optimistic concurrency, и command handler, который связывает Decider со стримом.
Параллельно полезно перечитать:
- 20. Aggregate Reservation, откуда
decideберёт FSM-правила. Decider не заменил агрегат, он построился поверх. - 27. Event Modeling, где given/when/then появились на доске. Сегодня они стали тестами.