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

Decider Жереми Шассена: три функции вместо агрегата

senior~35 мин

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

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 как четыре вещи. Тип состояния, и три значения:

  1. initial_state это состояние до первого события. У нас NotPlaced.
  2. decide(state, command) -> Result(events, error) это решение. Чистая функция: смотрит на текущее состояние и команду, возвращает список новых событий или доменную ошибку. Не меняет ничего, только решает.
  3. 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 появились на доске. Сегодня они стали тестами.