Один Decider, четыре рантайма: Gleam, Effect, Haskell и Rust бок о бок
В уроке про описание против интерпретации мы договорились, что Decider это высшая ступень одного приёма: домен описывается тремя чистыми функциями, а сохранение и рассылку берёт на себя отдельный интерпретатор. Ты уже собирал этот паттерн хотя бы раз: на Gleam в разделе про функциональное DDD, на Effect в разделе про Effect в проде, на Haskell или на Rust. Тот же домен бронирования отеля, та же тройка функций, четыре разных рантайма: BEAM, Node, GHC и нативный код без сборщика мусора.
Этот урок не учит Decider заново. Он ставит четыре готовые реализации рядом и показывает, что меняется при смене языка, а что остаётся неизменным. Вывод заберём наперёд: чистая логика домена переезжает почти дословно, меняется только бухгалтерия эффектов и способ выразить номинальный тип.
Сцена · одна модель, четыре рантайма
Перед нами один и тот же Reservation: бронь живёт как конечный автомат с пятью состояниями и реагирует на четыре команды. Все четыре реализации разобраны целиком в своих треках; здесь мы ставим их рядом, листинг к листингу. Домены сошлись до буквы: одинаковые команды, события и переходы. Это и делает сравнение честным.
Поиграй с автоматом выше: команды двигают состояние, на каждый успешный переход рождается событие, неверная команда отвергается. Дальше мы разберём, как одна и та же машина записана в типах двух языков.
Карта урока · что заберёшь
- увидишь шесть осей, по которым четыре языка расходятся, и поймёшь, что ни одна из них не про домен, а про язык;
- научишься читать все четыре записи Decider как один контракт;
- сможешь объяснить, почему
decideостаётся чистой даже там, где нужно текущее время; - получишь словарь, чтобы потом без труда добавить в это сравнение ещё один язык.
Общий домен · Reservation FSM
Состояние брони это сумма-тип, где каждый вариант это одна точка автомата. Отменённая бронь с заселённым гостем не выражается в типе: такой вариант некому сконструировать. Во всех четырёх языках состояний пять: NotPlaced, Reserved, CheckedIn, CheckedOut, Cancelled.
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 initial() -> Reservation {
NotPlaced
}export type ReservationState =
| { readonly _tag: 'NotPlaced' }
| ReservedState
| CheckedInState
| { readonly _tag: 'CheckedOut'; readonly reservationId: ReservationId }
| { readonly _tag: 'Cancelled'; readonly reservationId: ReservationId };
export const initial: ReservationState = { _tag: 'NotPlaced' };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)
initialState :: ReservationState
initialState = NotPlaced#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ReservationState {
#[default]
NotPlaced,
Reserved {
reservation_id: ReservationId,
guest: GuestEmail,
room: RoomNumber,
range: DateRange,
},
CheckedIn {
reservation_id: ReservationId,
guest: GuestEmail,
room: RoomNumber,
range: DateRange,
},
CheckedOut { reservation_id: ReservationId },
Cancelled { reservation_id: ReservationId },
}Разница чисто синтаксическая. Gleam, Haskell и Rust различают варианты по конструктору, Effect по полю _tag (это разметка для дискриминируемого объединения). Гарантия одна: компилятор знает все варианты и заставит обработать каждый.
Ось 1 · доменные типы
Идентификатор брони не должен быть голой строкой: иначе чужой id подставится в позицию своего, и компилятор смолчит. Лечится это номинальным типом: в Gleam это opaque type, в Effect это Schema.brand. Имя у приёма одно, реализация разная.
pub opaque type ReservationId {
ReservationId(value: String)
}
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))
}
}export const ReservationId = Schema.String.check(
Schema.isPattern(/^res_[a-z0-9]{8,}$/i, { title: 'ReservationId' }),
).pipe(Schema.brand('ReservationId'));
export type ReservationId = Schema.Schema.Type<typeof ReservationId>;module Hotel.Domain.Types
( ReservationId -- БЕЗ (..): конструктор остаётся внутри модуля
, mkReservationId
, unReservationId
) where
newtype ReservationId = ReservationId Text
deriving stock (Eq, Ord, Show)
mkReservationId :: Text -> Either ValueError ReservationId
mkReservationId raw
| isValid = Right (ReservationId raw)
| otherwise = Left (InvalidReservationId raw)
where
isValid = .../// Поле приватное: снаружи модуля `ReservationId(..)` не собрать.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ReservationId(String);
impl TryFrom<&str> for ReservationId {
type Error = ValueError;
fn try_from(raw: &str) -> Result<Self, Self::Error> {
if is_valid_reservation_id(raw) {
Ok(ReservationId(raw.to_owned()))
} else {
Err(ValueError::InvalidReservationId(raw.to_owned()))
}
}
}В Gleam конструктор ReservationId спрятан словом opaque: снаружи модуля его не вызвать, остаётся только smart-конструктор reservation_id, который проверяет инвариант и возвращает Result. В Effect ту же роль играет Schema.brand поверх проверки Schema.isPattern: схема одновременно описывает валидацию, тип и кодек для JSON. Gleam пишет проверку руками, Effect декларирует её регуляркой и получает разбор и сборку бесплатно.
Haskell прячет конструктор списком экспорта модуля: наружу уезжает имя типа без (..), и единственной дверью остаётся mkReservationId. Rust делает то же приватным полем структуры, а дверью работает стандартный трейт TryFrom. Четыре механики (ключевое слово opaque, схема с брендом, список экспорта, видимость поля), приём один: единственный конструктор с проверкой инварианта.
Ось 2 · форма Decider
Сам Decider это запись из трёх полей. Параметры одни и те же: тип команды, состояния, события и ошибки.
pub type Decider(command, state, event, error) {
Decider(
initial_state: state,
decide: fn(state, command) -> Result(List(event), error),
evolve: fn(state, event) -> state,
)
}export type Decider<Command, State, Event, Error> = {
readonly initial: State;
readonly decide: (state: State, command: Command) => Effect.Effect<ReadonlyArray<Event>, Error>;
readonly evolve: (state: State, event: Event) => State;
};-- Запись функций, а не класс типов: реализация это обычное значение.
data Decider st cmd ev err = Decider
{ initial :: st
, decide :: st -> cmd -> Timestamp -> Either err [ev]
, evolve :: st -> ev -> st
}pub trait Decider {
type Command;
type State;
type Event;
type Error;
fn initial(&self) -> Self::State;
fn decide(
&self,
state: &Self::State,
command: &Self::Command,
now: Timestamp,
) -> Result<Vec<Self::Event>, Self::Error>;
fn evolve(&self, state: Self::State, event: &Self::Event) -> Self::State;
}Вот первая содержательная развилка. В Gleam decide отдаёт Result(List(event), error): либо список фактов, либо ошибка, и больше ничего. В Effect возвращается Effect.Effect<ReadonlyArray<Event>, Error>. Это значит, что decide смеет дотянуться до сервисов рантайма (например, до часов), оставаясь при этом описанием, а не исполнением. evolve во всех четырёх языках просто state на входе и state на выходе, без всякой обёртки: она обязана быть чистой и тотальной.
Haskell и Rust решают развилку с decide третьим способом: время приезжает явным аргументом, а результат остаётся обычным Either или Result, без обёртки эффекта. Различается ещё носитель контракта. Gleam, Effect и Haskell описывают Decider записью с полями-функциями, Rust трейтом с ассоциированными типами. Запись это значение: её можно собрать в рантайме, обернуть логированием, передать параметром. Трейт это статический контракт: ноль стоимости и вывод реализации по типу, но одна реализация на тип.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.