Decider pattern на Effect.gen: decide, evolve, initial
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Decider pattern на Effect.gen
Сцена · команды и события это два разных языка
Возьми любую беседу с менеджером на ресепшене. Она звучит так: “хочу забронировать 101-й на третье”, “отмени бронь Иванова”, “гость заехал”. Это команды. У них настоящее время, у них императив и их можно отвергнуть (“извини, 101-й занят”).
Через час он же отчитается перед сменщиком: “забронировали 101-й, оформили заезд Иванова, отменили бронь Сидорова”. Это события. У них прошедшее время, они уже произошли, отменить их нельзя. Можно компенсировать (вернуть деньги, выселить раньше), но сам факт остаётся в журнале.
Это и есть фундаментальное разделение DDD. Команда это запрос на изменение, который может быть отвергнут. Событие это факт, который уже произошёл. UI и API живут в командах. Бизнес-память живёт в событиях. Между ними стоит decision-функция, которая берёт команду + текущее состояние и решает, какие события выпустить.
В этом уроке мы строим этот мостик в каноничной форме Жереми Шассена. Тройка decide + evolve + initial называется Decider.
Карта урока · что заберёшь домой
- Reservation FSM. Пять состояний, четыре команды, четыре события. Рисуем граф переходов.
- Тройка decide + evolve + initial. Канон Decider, типовая сигнатура, контракт.
- decide на Effect.gen с Match.value. Чистая бизнес-логика, обёрнута в Effect ради доступа к Clock.
- evolve как чистая reduce-функция. Никакого Effect: чистый редьюсер state и event в state.
- replay: восстановление state из событий. Базовая операция для всех остальных уроков.
- BDD-тест: verify(scenario, decider). Каноничный тестовый паттерн.
- Что не покрыто. Persistence (урок 16) и проекции (урок 17).
Раздел 1 · Reservation FSM
Сцена
Reservation в нашем отеле живёт в пяти состояниях. На границу с реальностью мы выходим через четыре команды; домен отвечает четырьмя событиями. Нарисуем картинку перед тем, как писать код.
PlaceReservation CheckInGuest CheckOutGuest
NotPlaced ─────────────────► Reserved ──────────────► CheckedIn ─────────────► CheckedOut
│ (×)
│ CancelReservation
▼
Cancelled
NotPlaced, начальное состояние, до первой команды.Reserved, бронь оформлена, гость пока не приехал.CheckedIn, гость заселился.CheckedOut, гость выехал, цикл закончен.Cancelled, бронь отменена.
Что не разрешено:
PlaceReservationнаReserved,CheckedIn,CheckedOut,Cancelled, дубль брони.CheckInGuestгде угодно, кромеReserved.CheckOutGuestгде угодно, кромеCheckedIn.CancelReservationвCheckedIn(гость уже в номере, отменять некого) и вCheckedOut/Cancelled(поздно).
Эти запреты, и есть инварианты домена. Дальше мы их кладём в decide, и больше ни в одном месте кода не повторяем.
Шаг 1 · Тип состояния
// examples/ddd-hotel/effect/src/domain/state.ts
import type { DateRange, GuestEmail, ReservationId, RoomNumber } from './types.ts';
export type ReservationState =
| { readonly _tag: 'NotPlaced' }
| {
readonly _tag: 'Reserved';
readonly reservationId: ReservationId;
readonly guest: GuestEmail;
readonly room: RoomNumber;
readonly range: DateRange;
}
| {
readonly _tag: 'CheckedIn';
readonly reservationId: ReservationId;
readonly guest: GuestEmail;
readonly room: RoomNumber;
readonly range: DateRange;
}
| { readonly _tag: 'CheckedOut'; readonly reservationId: ReservationId }
| { readonly _tag: 'Cancelled'; readonly reservationId: ReservationId };
export const initial: ReservationState = { _tag: 'NotPlaced' };
Что важно:
- Это размеченное объединение по
_tag. - В
ReservedиCheckedInлежит вся информация, нужная decider-у: гость, комната, диапазон. - В
CheckedOutиCancelledмы помним толькоreservationId, потому что бронь окончена, остальное не нужно.
Невозможные состояния (например, “отменённая бронь с активным check-in”) не выражаются в типе: их буквально нельзя сконструировать. Это и есть ориентир урока 14, применённый к state-машине.
Шаг 2 · Команды и события
Команды и события мы уже описали через Schema.TaggedStruct в уроке 02. Здесь только проговорим, что лежит в файлах:
// examples/ddd-hotel/effect/src/domain/commands.ts
export const PlaceReservation = Schema.TaggedStruct('PlaceReservation', {
reservationId: ReservationId,
guest: GuestEmail,
room: RoomNumber,
range: DateRange,
});
export const CancelReservation = Schema.TaggedStruct('CancelReservation', { reservationId: ReservationId });
export const CheckInGuest = Schema.TaggedStruct('CheckInGuest', { reservationId: ReservationId });
export const CheckOutGuest = Schema.TaggedStruct('CheckOutGuest', { reservationId: ReservationId });
export const ReservationCommand = Schema.Union([
PlaceReservation, CancelReservation, CheckInGuest, CheckOutGuest,
]);
// examples/ddd-hotel/effect/src/domain/events.ts
export const ReservationPlaced = Schema.TaggedStruct('ReservationPlaced', {
reservationId: ReservationId, guest: GuestEmail, room: RoomNumber, range: DateRange,
occurredAt: Schema.Number,
});
export const ReservationCancelled = Schema.TaggedStruct('ReservationCancelled', {
reservationId: ReservationId, occurredAt: Schema.Number,
});
export const GuestCheckedIn = Schema.TaggedStruct('GuestCheckedIn', {
reservationId: ReservationId, occurredAt: Schema.Number,
});
export const GuestCheckedOut = Schema.TaggedStruct('GuestCheckedOut', {
reservationId: ReservationId, occurredAt: Schema.Number,
});
export const ReservationEvent = Schema.Union([
ReservationPlaced, ReservationCancelled, GuestCheckedIn, GuestCheckedOut,
]);
Schema.Union принимает массив вариантов, а не список аргументов. Это общий приём Schema: там, где раньше была вариадика, теперь один аргумент-массив, и то же самое у Schema.Tuple и Schema.Literals.
Поле occurredAt (UTC-эпоха в миллисекундах) у каждого события, потому что это факт, а у факта есть момент времени. Команды этого поля не несут: они только просят сделать, время решит сама система в момент решения.
Что взять с собой
- FSM Reservation: пять состояний, четыре команды, четыре события.
- Состояние это размеченное объединение по
_tag. Невозможные комбинации полей не выражаются. - Команды без
occurredAt, события всегда сoccurredAt.
Раздел 2 · Тройка decide + evolve + initial
Сцена
Все слышали про “агрегат”. У Эванса агрегат это класс с публичными методами и приватными полями: ты дёргаешь метод, он мутирует поля, на выходе получаешь сохранённый объект. В FP-стиле тот же агрегат разворачивается в три чистые функции.
Идея словами
// examples/ddd-hotel/effect/src/domain/decider.ts
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;
};
Читаем эту сигнатуру:
initial, исходное состояние FSM (NotPlaced).decide(state, command), “если мы в этом состоянии и пришла такая команда, какие события выпустить или какую ошибку вернуть”. Чистая по бизнесу, эффект только для Clock и подобных сервисов.evolve(state, event), “если применить это событие к этому состоянию, какое будет новое состояние”. Полностью чистая, никаких эффектов.
Из этой тройки выводятся все остальные операции:
- replay:
events.reduce(evolve, initial)восстанавливает state. - handle:
events = decide(state, command),newState = events.reduce(evolve, state). - snapshot: периодически кешируем state в БД, чтобы не реплеить с нуля.
Это и есть Decider. Он универсальнее агрегата: одна и та же тройка одинаково работает с in-memory store, с Postgres event store, в BDD-тесте.
Шаг 1 · Типовой алиас для Reservation
import type { Effect } from 'effect';
import type { ReservationCommand } from './commands.ts';
import type { DomainError } from './errors.ts';
import type { ReservationEvent } from './events.ts';
import type { ReservationState } from './state.ts';
export type ReservationDecider = Decider<
ReservationCommand, ReservationState, ReservationEvent, DomainError
>;
DomainError это union наших tagged-ошибок из урока 14 плюс новых, специфичных для FSM:
// examples/ddd-hotel/effect/src/domain/errors.ts
import { Data } from 'effect';
export class InvalidStateTransition extends Data.TaggedError('InvalidStateTransition')<{
readonly reservationId: ReservationId;
readonly from: string;
readonly command: string;
}> {}
InvalidStateTransition это главная ошибка decider-а. Каждая попытка применить команду не в том состоянии заканчивается такой ошибкой. У неё есть from (имя текущего state) и command (имя команды), которые попадают в HTTP-ответ практически без обработки.
Что взять с собой
- Decider это три чистые функции:
initial,decide,evolve. decideэто бизнес-решение,evolveэто материализация в state.- Decider универсальнее агрегата: одна тройка работает с разной инфраструктурой.
Раздел 3 · decide на Effect.gen с Match.value
Сцена
Разберём decide по командам. Match.value из Effect разбирает команду по _tag. Каждая ветка возвращает Effect, типы автоматически складываются.
Шаг 1 · Заготовка
import { Clock, Effect, Match } from 'effect';
const decide = (
state: ReservationState,
command: ReservationCommand,
): Effect.Effect<ReadonlyArray<ReservationEvent>, DomainError> =>
Match.value(command).pipe(
Match.tag('PlaceReservation', (c) => placeReservation(state, c)),
Match.tag('CancelReservation', (c) => cancelReservation(state, c)),
Match.tag('CheckInGuest', (c) => checkInGuest(state, c)),
Match.tag('CheckOutGuest', (c) => checkOutGuest(state, c)),
Match.exhaustive,
);
Match.exhaustive на конце критичен: добавишь пятую команду в ReservationCommand, и компилятор заставит обработать её здесь. Никаких “забыл написать обработчик”.
Шаг 2 · PlaceReservation
const placeReservation = (
state: ReservationState,
command: PlaceReservation,
): Effect.Effect<ReadonlyArray<ReservationPlaced>, DomainError> =>
Effect.gen(function* () {
if (state._tag !== 'NotPlaced') {
return yield* Effect.fail(
new InvalidStateTransition({
reservationId: command.reservationId,
from: state._tag,
command: command._tag,
}),
);
}
const occurredAt = yield* Clock.currentTimeMillis;
return [
{
_tag: 'ReservationPlaced',
reservationId: command.reservationId,
guest: command.guest,
room: command.room,
range: command.range,
occurredAt,
},
];
});
Шаги:
- Проверка: команду
PlaceReservationмы принимаем только наNotPlaced. Любой другой state, и сразуInvalidStateTransition. Clock.currentTimeMillisэто default service, в R он не появляется. В тесте подменим наTestClockи получим детерминированное время.- Возвращаем массив из одного события
ReservationPlaced. Массив, а не одно событие, потому что одна команда может породить несколько событий (например,CheckInплюс автоматическийRoomBlockedдля уборки). Сегодня одно, завтра два, тип не меняется.
Шаг 3 · CancelReservation
const cancelReservation = (
state: ReservationState,
command: CancelReservation,
): Effect.Effect<ReadonlyArray<ReservationCancelled>, DomainError> =>
Effect.gen(function* () {
if (state._tag !== 'Reserved') {
return yield* Effect.fail(
new InvalidStateTransition({
reservationId: command.reservationId,
from: state._tag,
command: command._tag,
}),
);
}
const occurredAt = yield* Clock.currentTimeMillis;
return [
{ _tag: 'ReservationCancelled', reservationId: command.reservationId, occurredAt },
];
});
Cancel разрешён только из Reserved. Cancel из CheckedIn отвергаем: гость уже в номере, отменять некого. Cancel из Cancelled или CheckedOut тоже отвергаем: бронь уже не активна.
Шаг 4 · CheckInGuest и CheckOutGuest
const checkInGuest = (state: ReservationState, command: CheckInGuest) =>
Effect.gen(function* () {
if (state._tag !== 'Reserved') {
return yield* Effect.fail(
new InvalidStateTransition({
reservationId: command.reservationId, from: state._tag, command: command._tag,
}),
);
}
const occurredAt = yield* Clock.currentTimeMillis;
return [{ _tag: 'GuestCheckedIn', reservationId: command.reservationId, occurredAt }];
});
const checkOutGuest = (state: ReservationState, command: CheckOutGuest) =>
Effect.gen(function* () {
if (state._tag !== 'CheckedIn') {
return yield* Effect.fail(
new InvalidStateTransition({
reservationId: command.reservationId, from: state._tag, command: command._tag,
}),
);
}
const occurredAt = yield* Clock.currentTimeMillis;
return [{ _tag: 'GuestCheckedOut', reservationId: command.reservationId, occurredAt }];
});
Шаблон везде одинаковый: проверка state, время через Clock, одно событие на выходе. Каждый обработчик читается за десять секунд. Никаких репозиториев, никаких сервисов, чистая бизнес-логика.
Что взять с собой
Match.value(command).pipe(Match.tag(...))это типобезопасный разбор команды по_tag.Match.exhaustiveловит “забыл обработать новую команду” на этапе компиляции.- Каждый обработчик: проверка state, опционально Clock или Random, массив событий.
Effect.fail(new TaggedError(...))это контракт отказа, не throw.
Раздел 4 · evolve как чистая reduce-функция
Сцена
evolve это детерминированная функция. У неё нет Clock, нет Random, нет сервисов. На один и тот же (state, event) она всегда возвращает один и тот же state. Это критично для replay: если бы evolve зависел от времени, replay 100 событий давал бы 100 разных state в зависимости от того, когда мы реплеим.
Шаг 1 · Полная реализация
const evolve = (state: ReservationState, event: ReservationEvent): ReservationState =>
Match.value(event).pipe(
Match.tag('ReservationPlaced', (e): ReservationState => ({
_tag: 'Reserved',
reservationId: e.reservationId,
guest: e.guest,
room: e.room,
range: e.range,
})),
Match.tag('ReservationCancelled', (e): ReservationState => ({
_tag: 'Cancelled', reservationId: e.reservationId,
})),
Match.tag('GuestCheckedIn', (e): ReservationState => {
if (state._tag !== 'Reserved') return state;
return {
_tag: 'CheckedIn',
reservationId: e.reservationId,
guest: state.guest,
room: state.room,
range: state.range,
};
}),
Match.tag('GuestCheckedOut', (e): ReservationState => ({
_tag: 'CheckedOut', reservationId: e.reservationId,
})),
Match.exhaustive,
);
Обрати внимание на GuestCheckedIn: чтобы перейти в CheckedIn, нам нужны guest, room, range из текущего state. Если state не Reserved (защитная ветка на случай некорректного replay), просто возвращаем state без изменений.
Это пример defensive evolve: evolve никогда не падает. Если событие пришло в “невозможном” контексте, лучше тихо проигнорировать, чем сломать replay. Гарантирует это сам decide: невалидную команду он не пропускает, значит и невалидного события в стриме быть не должно.
Шаг 2 · initial
import { initial } from './state.ts';
export const reservationDecider: ReservationDecider = {
initial,
decide,
evolve,
};
initial = { _tag: 'NotPlaced' }. Всё, агрегат готов.
Что взять с собой
evolveэто чистая функция без Clock, Random, сервисов.- На
(state, event)она всегда возвращает один и тот же state. - “Невозможные” события
evolveтихо игнорирует. Контракт гарантируетdecide.
Раздел 5 · replay
Сцена
Replay это базовая операция всего event-sourced подхода. Берёшь массив событий, прогоняешь через evolve, получаешь текущее состояние. Никаких snapshot, никакой материализации; сама история событий и есть state, по требованию пересчитываемый в нужный формат.
Шаг 1 · Простая реализация
export const replay = (
events: Iterable<ReservationEvent>,
decider: ReservationDecider = reservationDecider,
): ReservationState => {
let state = decider.initial;
for (const event of events) {
state = decider.evolve(state, event);
}
return state;
};
Просто reduce. Iterable, чтобы можно было реплеить и массив, и стрим из БД, и тестовый генератор.
Шаг 2 · Где это пригодится
Replay нам понадобится в трёх местах:
- Тест Decider, given events, when command, then events. История событий это
events, начальное состояние получаем replay-ем. - Event Store handler (урок 16): загружаем стрим из БД, replay даёт текущее state, на нём dispatch новой команды.
- Snapshot machinery (за пределами этого курса): каждые N событий сохраняем
stateв БД, replay начинает не с initial, а со snapshot.
Сегодня нам нужен только пункт 1.
Что взять с собой
- Replay это
events.reduce(evolve, initial). - Replay чистый, детерминированный, без I/O.
- На replay-е держится event sourcing: state не хранится, он пересчитывается.
Раздел 6 · BDD-тест: verify(scenario, decider)
Сцена
Тестовый паттерн verify(scenario, decider) пришёл из Functional Event Sourcing. Сценарий это три части: given (история событий до сейчас), when (команда, которую мы пытаемся применить), then (ожидаемый исход: либо массив событий, либо ошибка).
Это и есть BDD в чистом виде, только вместо текста Cucumber у нас типизированный код.
Шаг 1 · Утилита runDecide
// examples/ddd-hotel/effect/test/decider.test.ts
import { Effect, Result } from 'effect';
import { TestClock } from 'effect/testing';
const at = 1_700_000_000_000;
const runDecide = (
history: ReadonlyArray<ReservationEvent>,
command: ReservationCommand,
) =>
Effect.gen(function* () {
yield* TestClock.setTime(at);
const state = replay(history);
return yield* Effect.result(reservationDecider.decide(state, command));
}).pipe(Effect.provide(TestClock.layer()));
Что тут происходит:
- Тестовые инструменты живут в отдельной точке входа
effect/testing, туда же переехалTestClock. Основной барельeffectдержит только рантайм. TestClock.setTime(at)фиксирует время.Clock.currentTimeMillisвнутриdecideвернёт ровноat. Тест становится детерминированным.replay(history)восстанавливает состояние до момента команды.Effect.resultоборачивает decide вResult<events, error>, чтобы мы могли проверить и удачный путь, и отказ.Resultэто тип “или значение, или ошибка”, и ветки в нём названы по делу:Result.isSuccessс полемsuccessиResult.isFailureс полемfailure.Effect.provide(TestClock.layer())подкладывает тестовые часы. Единого “тестового контекста на всё” больше нет, каждый тестовый сервис отдаётся своим слоем, и в требованиях видно ровно то, что ты подменил.toEqualSuccessиtoBeFailureв тестах ниже это наши матчеры дляResultиз 13 · Testing и code style. Без них каждая проверка распадалась бы наexpect(Result.isSuccess(result)).toBe(true)плюсifради сужения типа, аifв тесте это ветка, которая может молча не выполниться.
Шаг 2 · Тест удачного пути
import { describe, expect, it } from 'vitest';
import { PlaceReservation } from '../src/domain/commands.ts';
import { DateRange, Day, GuestEmail, ReservationId, RoomNumber } from '../src/domain/types.ts';
const id = ReservationId.make('res_abcd1234');
const guest = GuestEmail.make('alice@example.com');
const room = RoomNumber.make('101');
const range = DateRange.make({ checkIn: Day.make(20_260_101), checkOut: Day.make(20_260_103) });
describe('reservationDecider', () => {
it('PlaceReservation на пустой истории даёт ReservationPlaced', async () => {
const command = PlaceReservation.make({ reservationId: id, guest, room, range });
const result = await Effect.runPromise(runDecide([], command));
expect(result).toEqualSuccess([
{ _tag: 'ReservationPlaced', reservationId: id, guest, room, range, occurredAt: at },
]);
});
});
Это и есть BDD-сценарий один-в-один. Given: пустая история. When: команда. Then: одно событие с конкретным содержимым. Никаких моков, никакой инфраструктуры.
Шаг 3 · Тест отказа
it('повторный PlaceReservation на Reserved отвергается', async () => {
const history: ReservationEvent[] = [
{ _tag: 'ReservationPlaced', reservationId: id, guest, room, range, occurredAt: at },
];
const command = PlaceReservation.make({ reservationId: id, guest, room, range });
const result = await Effect.runPromise(runDecide(history, command));
expect(result).toBeFailure(InvalidStateTransition);
});
Given: бронь уже была. When: пытаемся забронировать снова. Then: InvalidStateTransition. Разбор один-в-один с человеческим “почему”: менеджер скажет “уже забронировано”, и это видно в _tag ошибки.
Шаг 4 · Полный сценарий жизни брони
it('replay даёт CheckedOut после полного цикла', () => {
const history: ReservationEvent[] = [
{ _tag: 'ReservationPlaced', reservationId: id, guest, room, range, occurredAt: at },
{ _tag: 'GuestCheckedIn', reservationId: id, occurredAt: at },
{ _tag: 'GuestCheckedOut', reservationId: id, occurredAt: at },
];
expect(replay(history)._tag).toBe('CheckedOut');
});
Полный жизненный цикл одного агрегата: оформили, заселили, выселили. Replay даёт CheckedOut. Если когда-то рефакторим evolve и ломаем переход, этот тест упадёт первым.
Шаг 5 · Запуск
cd examples/ddd-hotel/effect
pnpm test
Если предыдущий урок сделан правильно (типы лежат, branded работают, DateRange валидируется), все шесть-семь тестов проходят за двести миллисекунд. Никакой БД, никакого сервера. Это и есть скорость разработки на чистом decider-е: вся бизнес-логика покрывается тестами без инфраструктуры.
Что взять с собой
verify(scenario, decider)это given/when/then в коде. Три строки на сценарий, никакого Cucumber.TestClock.setTimeфиксирует время, тесты детерминированные.Effect.resultоборачивает результат вResult, чтобы можно было проверить и удачный путь, и отказ.
Раздел 7 · Что не покрыто
Decider даёт нам поведение, не persistence. Что не делает этот урок:
- Не сохраняет события в БД. Стрим живёт только в массиве в памяти теста. Persistence будет в уроке 16.
- Не работает с параллельной записью (concurrent writers). Optimistic concurrency, тоже урок 16.
- Не строит read-model. UI хочет видеть “список текущих гостей”, а не разворачивать каждый стрим. Это урок 17.
В этой точке у нас уже работает сердце домена. Всё остальное это обвязка вокруг: где хранить события, как их рассылать, как материализовать в read-model. Сердце меняется редко, обвязка часто. Это и есть Onion Architecture: чистый домен в центре, инфраструктура снаружи.
Чек-лист
ДЗ
Дальше
Следующий урок · 16. Event Store на @effect/sql-pg. Берём reservationDecider и складываем события в Postgres. Появляется optimistic concurrency, боль хранения, и понимание, что Decider остаётся тем же самым: интерфейс к домену не меняется, меняется только инфраструктура вокруг.
Параллельно полезно перечитать:
- 04-services-and-layers, про
Context.ServiceиLayer. Decider не зависит от Layer, но Event Store в следующем уроке это уже честный сервис. - 03-errors, про tagged-ошибки и
catchTag.InvalidStateTransitionэто твоя первая доменная tagged-ошибка, и её_tagнапрямую попадает в HTTP-ответ.