Aggregate Reservation: стек инвариантов через result.try
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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 без классов суть остаётся, только техника меняется:
- Граница консистентности. Все инварианты, которые нужно защищать вместе, живут внутри одного типа (
Reservation). Гость, комната, диапазон дат, состояние, всё связано одним id. - Корень. Снаружи модуля виден только
Reservation. Поля смотреть можно (это ADT), создавать и менять, только через публичные функции. - 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, это самое прямое применение этого правила.