Раздел 25 · Effect-TS

Decider pattern на Effect.gen: decide, evolve, initial

senior~70 мин

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

Decider pattern на Effect.gen

Сцена · команды и события это два разных языка

Возьми любую беседу с менеджером на ресепшене. Она звучит так: “хочу забронировать 101-й на третье”, “отмени бронь Иванова”, “гость заехал”. Это команды. У них настоящее время, у них императив и их можно отвергнуть (“извини, 101-й занят”).

Через час он же отчитается перед сменщиком: “забронировали 101-й, оформили заезд Иванова, отменили бронь Сидорова”. Это события. У них прошедшее время, они уже произошли, отменить их нельзя. Можно компенсировать (вернуть деньги, выселить раньше), но сам факт остаётся в журнале.

Это и есть фундаментальное разделение DDD. Команда это запрос на изменение, который может быть отвергнут. Событие это факт, который уже произошёл. UI и API живут в командах. Бизнес-память живёт в событиях. Между ними стоит decision-функция, которая берёт команду + текущее состояние и решает, какие события выпустить.

В этом уроке мы строим этот мостик в каноничной форме Жереми Шассена. Тройка decide + evolve + initial называется Decider.

Карта урока · что заберёшь домой

  1. Reservation FSM. Пять состояний, четыре команды, четыре события. Рисуем граф переходов.
  2. Тройка decide + evolve + initial. Канон Decider, типовая сигнатура, контракт.
  3. decide на Effect.gen с Match.value. Чистая бизнес-логика, обёрнута в Effect ради доступа к Clock.
  4. evolve как чистая reduce-функция. Никакого Effect: чистый редьюсер state и event в state.
  5. replay: восстановление state из событий. Базовая операция для всех остальных уроков.
  6. BDD-тест: verify(scenario, decider). Каноничный тестовый паттерн.
  7. Что не покрыто. 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,
      },
    ];
  });

Шаги:

  1. Проверка: команду PlaceReservation мы принимаем только на NotPlaced. Любой другой state, и сразу InvalidStateTransition.
  2. Clock.currentTimeMillis это default service, в R он не появляется. В тесте подменим на TestClock и получим детерминированное время.
  3. Возвращаем массив из одного события 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 нам понадобится в трёх местах:

  1. Тест Decider, given events, when command, then events. История событий это events, начальное состояние получаем replay-ем.
  2. Event Store handler (урок 16): загружаем стрим из БД, replay даёт текущее state, на нём dispatch новой команды.
  3. 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-ответ.