Раздел 21 · Haskell

Decider на Haskell: decide, evolve, replay и явное время

senior~70 мин

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

Decider на Haskell: decide, evolve, replay и явное время

Сцена · откуда тема

Словарь домена готов: в прошлом уроке мы завели типы, которые невозможно собрать невалидными. Но домен это не только словарь, это ещё и правила. Бронь нельзя отменить дважды. Гостя нельзя заселить в бронь, которой нет. Выехать может только тот, кто заселился.

Обычно такие правила расползаются по коду: часть в контроллере, часть в сервисе, часть в триггере базы. Через полгода на вопрос «а можно ли отменить заселённую бронь» никто не отвечает без чтения трёх файлов. Сегодня мы соберём все правила агрегата в одно место и в одну форму, у которой есть имя и канон: Decider.

Раздел 1 · Команда и событие это два разных языка

Первое различие, которое стоит вбить в рефлекс.

Команда это просьба в настоящем времени: PlaceReservation, CheckInGuest. Её можно отвергнуть. Она не факт, а намерение.

Событие это факт в прошедшем времени: ReservationPlaced, GuestCheckedIn. Оно уже случилось, спорить с ним поздно, отменить нельзя (можно лишь добавить компенсирующее событие).

Из этого различия следует всё остальное. Команды не хранятся, события хранятся. Команды валидируются, события применяются. Команда порождает ноль, одно или несколько событий.

data ReservationCommand
  = PlaceReservation
      { reservationId :: ReservationId
      , guest :: GuestEmail
      , room :: RoomNumber
      , range :: DateRange
      }
  | CancelReservation {reservationId :: ReservationId}
  | CheckInGuest {reservationId :: ReservationId}
  | CheckOutGuest {reservationId :: ReservationId}
  deriving stock (Eq, Show)

Обрати внимание: команды не сериализуются, у них нет инстансов JSON. Они живут ровно от границы HTTP до вызова decide и умирают. В стрим уезжают события, и вот у них формат провода описан явно:

-- | Момент времени в миллисекундах UTC. Отдельный newtype, чтобы decide брал
-- время явным аргументом, а не из скрытых часов.
newtype Timestamp = Timestamp Int64
  deriving stock (Eq, Ord, Show)

data ReservationEvent
  = ReservationPlaced
      { reservationId :: ReservationId
      , guest :: GuestEmail
      , room :: RoomNumber
      , range :: DateRange
      , occurredAt :: OccurredAt
      }
  | ReservationCancelled
      { reservationId :: ReservationId
      , occurredAt :: OccurredAt
      }
  | GuestCheckedIn
      { reservationId :: ReservationId
      , occurredAt :: OccurredAt
      }
  | GuestCheckedOut
      { reservationId :: ReservationId
      , occurredAt :: OccurredAt
      }
  deriving stock (Eq, Show, Generic)

У каждого события есть occurredAt. Событие это запись в журнале, и запись без времени бесполезна: по ней нельзя ни восстановить хронологию, ни разобрать инцидент.

Раздел 2 · Форма события на проводе

События переживут твой код. Через год их будет читать другой сервис, другая версия домена, а то и другой язык. Поэтому формат сериализации это часть контракта, и его стоит задать явно, а не отдать на волю дефолтов aeson.

-- | Одни общие Options и для ToJSON, и для FromJSON: внутренний тег в поле
-- type плюс перевод имён полей в snake_case.
eventOptions :: Options
eventOptions =
  defaultOptions
    { sumEncoding = TaggedObject {tagFieldName = "type", contentsFieldName = "contents"}
    , fieldLabelModifier = camelToSnake
    , constructorTagModifier = id
    }

instance ToJSON ReservationEvent where
  toJSON = genericToJSON eventOptions
  toEncoding = genericToEncoding eventOptions

instance FromJSON ReservationEvent where
  parseJSON = genericParseJSON eventOptions

Ключевое слово здесь внутренний тег. На проводе получается плоский объект:

{"type":"ReservationPlaced","reservation_id":"res_abcd1234","occurred_at":1700000000000}

Такой объект ложится прямо в колонку jsonb без обёрток, а поле type совпадает с колонкой type в таблице событий, которую заведём в следующем уроке. Плюс формат байт-в-байт совпадает с Rust-портом, где то же самое даёт serde с атрибутом тега. Совместимость не случайность, а требование: один и тот же журнал должны читать все порты трека, и тест на точную форму провода стоит написать сразу.

Раздел 3 · Состояние как конечный автомат

Теперь состояние агрегата. Соблазн описать его записью с флагами (isCancelled, isCheckedIn, Maybe room) велик, и почти всегда это ошибка: четыре булевых поля дают шестнадцать комбинаций, из которых осмысленны четыре, а остальные двенадцать придётся отсекать проверками в рантайме.

Правильный ход это сумма типов, где каждый вариант отдельная точка автомата:

data ReservationState
  = NotPlaced
  | Reserved
      { reservationId :: ReservationId
      , guest :: GuestEmail
      , room :: RoomNumber
      , range :: DateRange
      }
  | CheckedIn
      { reservationId :: ReservationId
      , guest :: GuestEmail
      , room :: RoomNumber
      , range :: DateRange
      }
  | CheckedOut {reservationId :: ReservationId}
  | Cancelled {reservationId :: ReservationId}
  deriving stock (Eq, Show)

Автомат читается по типу: NotPlaced переходит в Reserved, оттуда в CheckedIn или Cancelled, из CheckedIn в CheckedOut. Отменённая бронь с активным заселением просто не выражается: нет такого конструктора. Это тот же приём, что в уроке про алгебраические типы, только приложенный к жизненному циклу, а не к значению.

Заметь и другое: у CheckedOut и Cancelled нет полей room и guest. Они не нужны: бронь завершена, комната свободна. Состояние хранит ровно то, что требуется для будущих решений, и ни байтом больше. Всё остальное лежит в журнале событий, откуда его при надобности достанет проекция.

Раздел 4 · Decider: запись функций, а не класс типов

Канон Шассена это тройка:

initial :: st
decide  :: st -> cmd -> Timestamp -> Either err [ev]
evolve  :: st -> ev -> st

В Haskell первая мысль это описать её классом типов: class Decider st cmd ev err | st -> cmd ev err. Мы сознательно делаем иначе и берём запись функций:

-- | Обобщённый контракт Decider, абстрагированный по типам состояния, команды,
-- события и ошибки: Hotel это лишь одна его реализация.
data Decider st cmd ev err = Decider
  { initial :: st
  , decide :: st -> cmd -> Timestamp -> Either err [ev]
  , evolve :: st -> ev -> st
  }

Почему так, а не классом. Класс типов привязывает ровно одну реализацию к типу: второй Decider для того же состояния уже потребует newtype-обёртки. Запись же это обычное значение: её можно построить в рантайме, обернуть логированием, подменить в тесте, передать параметром в общую функцию. И главное для учебного трека: видно, что decide и evolve это просто функции, а не магия компилятора.

Конкретный агрегат становится значением:

reservationDecider :: Decider ReservationState ReservationCommand ReservationEvent DomainError
reservationDecider =
  Decider
    { initial = initialState
    , decide = decideReservation
    , evolve = evolveReservation
    }

Обрати внимание на четыре параметра типа. Они и есть контракт: состояние, команда, событие, ошибка. Заметив такую подпись в чужом коде, ты сразу знаешь, где искать правила домена.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.