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

Aggregate Reservation: стек инвариантов через result.try

middle-senior~45 мин

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

Aggregate Reservation: стек инвариантов через result.try

Четвёртая ката, самая объёмная в первом блоке. Собираем Aggregate, главную единицу тактического DDD. На Gleam она получается как ADT из пяти состояний и набор pure-функций, которые двигают state по правилам FSM. Никаких классов, никаких сеттеров, никакого скрытого state.

Сцена · бронирование как машина состояний

Бронь живёт не одним моментом, а пятью точками: оформили, заехали, выехали (или отменили). Между точками валидные переходы. Между не-валидными запрет: после check-in отменять некого, после выселения заселять некого.

Если оставить состояние строкой status: "reserved" или status: "cancelled", инвариант “нельзя отменить выселенного” приходится держать в каждой функции, которая трогает Reservation. Если описать состояние как ADT с пятью отдельными конструкторами, неправильный переход просто не скомпилируется.

Это и есть Aggregate по Эвансу на Gleam: ADT состояний + публичные функции-переходы, каждая возвращает Result(state, error).

Цели каты

  • Спроектировать Reservation как ADT из пяти состояний (NotPlaced, Reserved, CheckedIn, CheckedOut, Cancelled).
  • Написать четыре функции-перехода с Result-обёрткой.
  • Понять, как result.try убирает вложенные case-цепочки.
  • Уяснить, почему все функции чистые, без I/O, без БД, без логирования.

К концу каты в examples/ddd-hotel/gleam/src/domain/reservation.gleam будет ~170 строк, и в тестах семь сценариев, покрывающих happy и rejection пути всех переходов.

Концепт · Aggregate

Эванс ввёл Aggregate для того, чтобы провести границу транзакции в OOP-системе. Если ты меняешь корзину покупателя, ты меняешь её целиком, как один атомарный объект. Снаружи виден только корень (Cart), позиции внутри (CartItem) недоступны как самостоятельные сущности.

В Gleam без классов суть остаётся, только техника меняется:

  1. Граница консистентности. Все инварианты, которые нужно защищать вместе, живут внутри одного типа (Reservation). Гость, комната, диапазон дат, состояние, всё связано одним id.
  2. Корень. Снаружи модуля виден только Reservation. Поля смотреть можно (это ADT), создавать и менять, только через публичные функции.
  3. Pure-операции. Каждый переход это функция (state, args) -> Result(state, error). Никакого db.save, никакого logger.log. Это будет в следующем уроке (Repository, 22).

Невалидные состояния выражаются не через if, а через отсутствие конструктора. У Cancelled нет полей guest или room, потому что после отмены они не важны. У CheckedOut тоже только id. Это значит, после cancel или check_out функция check_in буквально не сможет применить эту запись (нет match на нужный конструктор).

Концепт · FSM как ADT

Состояния Reservation:

        place             check_in            check_out
NotPlaced ─────────► Reserved ─────────► CheckedIn ─────────► CheckedOut
                        │                    (×)
                        │ cancel
                        ▼
                    Cancelled

Пять имён, четыре стрелки. На Gleam:

pub type Reservation {
  NotPlaced
  Reserved(id: ReservationId, guest: CustomerId, room: RoomNumber, range: DateRange)
  CheckedIn(id: ReservationId, guest: CustomerId, room: RoomNumber, range: DateRange)
  CheckedOut(id: ReservationId)
  Cancelled(id: ReservationId)
}

Заметь, что:

  • В Reserved и CheckedIn лежит полная информация: id, гость, комната, даты. Это нужно для дальнейших операций (например, при check-in поднимем диапазон, чтобы потом считать ночёвки).
  • В CheckedOut и Cancelled только id. Бронь окончена, остальное не интересно.
  • NotPlaced без полей. Это начальное состояние, у него нет id, потому что брони ещё не было.

Это образец typestate, программирования по состояниям-типам: у CheckedOut нет полей guest и room, поэтому функции, которым они нужны, к нему просто не применить. Тот же приём на Haskell, с примером открытого и закрытого файла, в моделировании домена через ADT, а на архитектурном уровне в тактических решениях по автоматам.

Языковые механики Gleam · result.try

Когда у тебя несколько шагов, каждый возвращает Result, без хелпера получается лестница:

case place(initial, id, guest, room, range) {
  Ok(reserved) -> case check_in(reserved) {
    Ok(checked_in) -> case check_out(checked_in) {
      Ok(checked_out) -> Ok(checked_out)
      Error(e) -> Error(e)
    }
    Error(e) -> Error(e)
  }
  Error(e) -> Error(e)
}

Та же логика с result.try:

use placed <- result.try(place(initial, id, guest, room, range))
use checked_in <- result.try(check_in(placed))
check_out(checked_in)

Три строки вместо девяти. Каждый use ... <- result.try(...) означает: “если Ok(value), привяжи value к имени и продолжай; если Error, выкини его сразу как результат всей цепочки”. Эквивалент do-нотации в Haskell или ?-оператора в Rust.

use в Gleam, это сахар над CPS-нотацией: компилятор раскрывает в result.try(..., fn(placed) { ... }). Тебе это знать не обязательно, главное помнить идиому use ... <- try(...).

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

Открой examples/ddd-hotel/gleam/src/domain/reservation.gleam. Это самый большой файл первого блока. Нужно:

pub opaque type ReservationId
pub opaque type RoomNumber
pub type DateRange { DateRange(check_in: Int, check_out: Int) }

pub type Reservation {
  NotPlaced
  Reserved(id, guest, room, range)
  CheckedIn(id, guest, room, range)
  CheckedOut(id)
  Cancelled(id)
}

// Smart-конструкторы
pub fn reservation_id(raw: String) -> Result(ReservationId, DomainError)
pub fn room_number(raw: String) -> Result(RoomNumber, DomainError)
pub fn date_range(check_in: Int, check_out: Int) -> Result(DateRange, DomainError)

// Initial и переходы
pub fn initial() -> Reservation
pub fn place(state, id, guest, room, range) -> Result(Reservation, DomainError)
pub fn cancel(state) -> Result(Reservation, DomainError)
pub fn check_in(state) -> Result(Reservation, DomainError)
pub fn check_out(state) -> Result(Reservation, DomainError)

Контракты переходов:

  • place: разрешён только из NotPlaced. Иначе InvalidStateTransition(from, "PlaceReservation").
  • cancel: разрешён только из Reserved. После check-in отменять некого, гость уже в номере.
  • check_in: разрешён только из Reserved.
  • check_out: разрешён только из CheckedIn.
  • На любой другой переход возвращаем InvalidStateTransition(from: state_name(state), command: имя_команды).

Smart-конструкторы:

  • reservation_id: должен начинаться с "res_" и быть длиной >= 12.
  • room_number: 3-4 символа, только цифры.
  • date_range: check_in < check_out. Иначе InvalidDateRange(...).

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

Подсказки

  • is_all_digits через рекурсию по string.to_graphemes. Gleam stdlib не даёт regex, но рекурсия на 5 строк прозрачна.
  • state_name(state: Reservation) -> String хелпер, который возвращает "NotPlaced", "Reserved" и так далее. Используется при формировании InvalidStateTransition.from.
  • В тестах удобно завести fn fixture() который возвращает tuple #(id, guest, room, range) валидных значений. Это убирает повторение в каждом тесте.

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

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

import gleam/string

import domain/customer.{type CustomerId}
import domain/errors.{
  type DomainError, InvalidDateRange, InvalidReservationId, InvalidRoomNumber,
  InvalidStateTransition,
}

pub opaque type ReservationId {
  ReservationId(value: String)
}

pub opaque type RoomNumber {
  RoomNumber(value: String)
}

pub type DateRange {
  DateRange(check_in: Int, check_out: Int)
}

pub type Reservation {
  NotPlaced
  Reserved(
    id: ReservationId,
    guest: CustomerId,
    room: RoomNumber,
    range: DateRange,
  )
  CheckedIn(
    id: ReservationId,
    guest: CustomerId,
    room: RoomNumber,
    range: DateRange,
  )
  CheckedOut(id: ReservationId)
  Cancelled(id: ReservationId)
}

pub fn reservation_id(raw: String) -> Result(ReservationId, DomainError) {
  let trimmed = string.trim(raw)
  case string.starts_with(trimmed, "res_") && string.length(trimmed) >= 12 {
    True -> Ok(ReservationId(trimmed))
    False -> Error(InvalidReservationId(raw))
  }
}

pub fn room_number(raw: String) -> Result(RoomNumber, DomainError) {
  let trimmed = string.trim(raw)
  let length = string.length(trimmed)
  case length >= 3 && length <= 4 && is_all_digits(trimmed) {
    True -> Ok(RoomNumber(trimmed))
    False -> Error(InvalidRoomNumber(raw))
  }
}

pub fn date_range(check_in: Int, check_out: Int) -> Result(DateRange, DomainError) {
  case check_in < check_out {
    True -> Ok(DateRange(check_in, check_out))
    False -> Error(InvalidDateRange("check_in должен быть строго меньше check_out"))
  }
}

pub fn initial() -> Reservation {
  NotPlaced
}

pub fn place(
  state: Reservation,
  id: ReservationId,
  guest: CustomerId,
  room: RoomNumber,
  range: DateRange,
) -> Result(Reservation, DomainError) {
  case state {
    NotPlaced -> Ok(Reserved(id, guest, room, range))
    _ -> Error(InvalidStateTransition(
      from: state_name(state),
      command: "PlaceReservation",
    ))
  }
}

pub fn cancel(state: Reservation) -> Result(Reservation, DomainError) {
  case state {
    Reserved(id, _, _, _) -> Ok(Cancelled(id))
    _ -> Error(InvalidStateTransition(
      from: state_name(state),
      command: "CancelReservation",
    ))
  }
}

pub fn check_in(state: Reservation) -> Result(Reservation, DomainError) {
  case state {
    Reserved(id, guest, room, range) -> Ok(CheckedIn(id, guest, room, range))
    _ -> Error(InvalidStateTransition(
      from: state_name(state),
      command: "CheckInGuest",
    ))
  }
}

pub fn check_out(state: Reservation) -> Result(Reservation, DomainError) {
  case state {
    CheckedIn(id, _, _, _) -> Ok(CheckedOut(id))
    _ -> Error(InvalidStateTransition(
      from: state_name(state),
      command: "CheckOutGuest",
    ))
  }
}

fn state_name(state: Reservation) -> String {
  case state {
    NotPlaced -> "NotPlaced"
    Reserved(_, _, _, _) -> "Reserved"
    CheckedIn(_, _, _, _) -> "CheckedIn"
    CheckedOut(_) -> "CheckedOut"
    Cancelled(_) -> "Cancelled"
  }
}

(хелперы is_all_digits и пара рекурсивных функций для проверки цифр опущены, они лежат в examples/ddd-hotel/gleam/src/domain/reservation.gleam).

Тесты · BDD стиль

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

import gleam/result
import gleeunit/should

import domain/customer
import domain/reservation

fn fixture() {
  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)
  #(id, guest, room, range)
}

pub fn place_from_initial_test() {
  let #(id, guest, room, range) = fixture()
  let assert Ok(state) =
    reservation.place(reservation.initial(), id, guest, room, range)
  state
  |> should.equal(reservation.Reserved(id, guest, room, range))
}

pub fn full_lifecycle_test() {
  let #(id, guest, room, range) = fixture()
  let initial = reservation.initial()

  let assert Ok(final_state) = {
    use placed <- result.try(reservation.place(initial, id, guest, room, range))
    use checked_in <- result.try(reservation.check_in(placed))
    reservation.check_out(checked_in)
  }

  final_state
  |> should.equal(reservation.CheckedOut(id))
}

pub fn cancel_after_check_in_rejected_test() {
  let #(id, guest, room, range) = fixture()
  let assert Ok(reserved) =
    reservation.place(reservation.initial(), id, guest, room, range)
  let assert Ok(checked_in) = reservation.check_in(reserved)

  reservation.cancel(checked_in)
  |> should.be_error()
}

Это BDD один-в-один:

  • Given: начальное состояние плюс фикстура валидных значений.
  • When: применили команду (или цепочку команд).
  • Then: ожидаемое состояние или ожидаемая ошибка.

full_lifecycle_test показывает result.try в действии: три шага place -> check_in -> check_out выстраиваются в линейный код, и если любой упадёт, цепочка останавливается с этой ошибкой.

Критика · что ещё нет

Сегодня мы построили половину Decider-а из урока 28. Чего не хватает:

  • Контракта со временем. Сейчас в Reserved и CheckedIn лежат сами даты как Int, но “момент check-in” мы не записываем. В уроке 21 (Domain Events) появятся события с occurred_at, и тогда check_in будет принимать now: Int явно.
  • Возврата событий. check_in возвращает новый state, но не возвращает событие “GuestCheckedIn”. В уроке 21 мы перепишем сигнатуру: place(state, command) -> Result(List(Event), DomainError). Это и есть decide Шассена.
  • Конкурентного доступа. Сейчас функции pure, никакого state-shared. Optimistic concurrency (expectedVersion) появляется в уроке 29 (event sourcing), там же optimistic-conflict при двойном place на одну и ту же комнату.
  • Cross-aggregate правил. “Нельзя забронировать комнату, которая уже занята на эти даты” это правило не агрегата Reservation (он один-в-один). Это правило репозитория или отдельной saga. Разберём в уроке 22.

Это и есть тактическая декомпозиция DDD: одна ката добавляет один слой, и каждый следующий урок поднимает планку до production-уровня.

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

Effect-вариант (examples/ddd-hotel/effect/src/domain/decider.ts) делает то же самое через Effect.gen и Match.value. Размер кода схожий, синтаксис другой:

  • Effect: каждый обработчик это Effect.gen(function* () { ... }) с yield* Effect.fail(...).
  • Gleam: каждый обработчик это case state { ... -> Ok(...); _ -> Error(...) }.

Сильная сторона Effect: канал R тащит Clock, поэтому occurredAt берётся из default-сервиса, и тесты подменяют его на TestClock. В Gleam время в этой кате мы вообще не трогаем (оно появится в уроке 21 как явный параметр now: Int), потому что Gleam не даёт встроенной capability-системы. Это нормально: в простых случаях явный параметр лучше неявной зависимости, в сложных, наоборот.

Takeaway

Одна фраза:

Aggregate на Gleam, это ADT состояний + чистые функции-переходы. Невалидный переход не выражается в pattern matching, и потому невозможен.

ДЗ

Дальше

Следующая ката · 21. Domain Events, команды и события как ADT. Берём наши переходы и разворачиваем их в пару “команда + событие”: сам факт перехода становится сущностью первого класса. Это и есть подготовка к Decider Шассена в уроке 28.

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

  • 05. Рекурсия, fold, Result, про result.try и линеаризацию ошибок. Сегодня это центральный приём.
  • 16. Functional DDD intro, раздел про North Star “невозможные состояния невыразимы”. Aggregate, это самое прямое применение этого правила.