В уроке 116 тесты научились ловить ошибки в поведении. Но эта ошибка не в поведении одной функции. Она в форме данных: журнал разрешает состояние, которого у письма быть не может. Тест такое поймает, только если кто-то догадается его написать. Тип ловит это всегда, если состояние нельзя даже записать.
Сегодня три вопроса про устройство кода: где проходят границы сервисов линии, как запретить письму невозможные состояния и как объяснить линии, что t-02 и T-2 это одна башня.
Слайсы: код по делу, а не по слою
Раскладка, которую видишь в каждом втором проекте, режет код по слоям:
src/
services/ letters.ts towers.ts journal.ts
repos/ letters.ts towers.ts
handlers/ letters.ts towers.ts
types/ letter.ts tower.ts
Чтобы поменять правило доставки, ты открываешь четыре папки. Папки отвечают на вопрос «каким механизмом это сделано», а приходишь ты с вопросом «где письма».
Слайс режет иначе, по делу:
src/
letters/ letter.ts step.ts journal.ts layer.ts index.ts
towers/ tower-id.ts book.ts layer.ts index.ts
mail/ handlers.ts index.ts
Всё про письма лежит в letters/, а снаружи видно только то, что отдаёт index.ts: тип письма, сервис журнала и его слой. Правило доставки меняется в одной папке.
Сервисы и слои ты собирал с урока 47 по урок 49. Сколько их нарезать, решают три проверки:
- Одна причина меняться. Журнал меняется, когда меняются правила писем. Книга башен, когда Магистраль меняет формат. Это два сервиса.
- Подмена в тесте. Что тест подставляет, как слои в уроке 53, то просится в сервис. Книга башен ходит в сеть, её подменяют. Чистую функцию
stepподменять незачем. - Не сервис на функцию. Сервис с одним методом, который зовут из одного места, это лишняя бумага. Чистая функция в файле слайса проще.
step и есть сердце урока: правила письма, которым не нужен ни слой, ни эффект.
Флаги врут
Где тут дыра
interface Entry {
delivered: boolean;
returned: boolean;
at?: string;
reason?: string;
}
const onReturned = (entry: Entry, reason: string) => {
entry.returned = true;
entry.reason = reason;
};
const onDelivered = (entry: Entry, at: string) => {
entry.delivered = true;
entry.at = at;
};
На его примере всё верно: письмо вернулось, у записи returned: true и причина. Но T-5 прислала квитанцию с опозданием, уже после возврата, и onDelivered честно ставит второй флажок: delivered: true и returned: true сразу. Ровно та страница, которую принёс Пондер.
Посчитай, что разрешает этот тип. Два флага и два необязательных поля дают шестнадцать форм записи: доставлено без башни, возврат без причины, всё сразу. Осмысленны четыре. Остальные двенадцать тип пропускает молча.
Состояния вместо флагов
Перевернём вопрос. Не «какие флажки у письма», а «в каком оно состоянии». Состояний четыре, и у каждого свои поля:
import { Data, type HashSet } from 'effect';
import type { TowerId } from '../towers';
export type Letter = Data.TaggedEnum<{
Accepted: {};
InTransit: {
readonly at: TowerId;
readonly path: HashSet.HashSet<TowerId>;
readonly hops: number;
};
Delivered: { readonly at: TowerId; readonly hops: number };
Returned: { readonly reason: string };
}>;
export const Letter = Data.taggedEnum<Letter>();
Data.TaggedEnum ты видел в уроке 44 на вердикте кадра. Доставленное письмо без башни не построить: у Delivered поле at обязательно. Доставленное и вернувшееся сразу не построить тем более: тег один. Двенадцать бессмысленных форм исчезли, потому что для них нет конструктора.
Письмо не меняется. Оно переходит в новое состояние по ходу:
export type Move = Data.TaggedEnum<{
Hop: { readonly to: TowerId };
Arrive: {};
Fail: { readonly reason: string };
}>;
export const Move = Data.taggedEnum<Move>();
export class IllegalMove extends Data.TaggedError('IllegalMove')<{
readonly from: Letter['_tag'];
readonly move: Move['_tag'];
readonly reason: string;
}> {}
Это машина состояний. Состояния, ходы и одна функция step(letter, move), которая отвечает новым письмом или IllegalMove. Таблица правил помещается на салфетку: из «принято» можно уйти в путь или вернуть, из «в пути» на следующую башню, к адресату или назад, из «доставлено» и «вернулось» никуда.
Файл на состояние
Пока состояний четыре, step пишется одним switch. Когда появятся «задержано» и «ждёт выкупа», switch разрастётся на экран, и правила разных состояний перемешаются. Поэтому машину кладут по файлу на состояние:
src/letters/
letter.ts Letter, Move, IllegalMove
accepted.ts ходы из «принято»
in-transit.ts ходы из «в пути»
delivered.ts ходы из «доставлено»
returned.ts ходы из «вернулось»
step.ts выбор файла по тегу
Каждый файл отвечает на один вопрос: что можно сделать с письмом в этом состоянии. Вот «принято»:
import { HashSet, Result, type Data } from 'effect';
import { IllegalMove, Letter, Move } from './letter';
type Accepted = Data.TaggedEnum.Value<Letter, 'Accepted'>;
export const fromAccepted = (letter: Accepted, move: Move): Result.Result<Letter, IllegalMove> =>
Move.$match(move, {
Hop: ({ to }) => Result.succeed(Letter.InTransit({ at: to, path: HashSet.make(to), hops: 1 })),
Fail: ({ reason }) => Result.succeed(Letter.Returned({ reason })),
Arrive: () =>
Result.fail(
new IllegalMove({ from: letter._tag, move: 'Arrive', reason: 'письмо ещё не в пути' }),
),
});
А step.ts только выбирает файл:
export const step = (letter: Letter, move: Move): Result.Result<Letter, IllegalMove> =>
Letter.$match(letter, {
Accepted: (accepted) => fromAccepted(accepted, move),
InTransit: (inTransit) => fromInTransit(inTransit, move),
Delivered: (delivered) => fromDelivered(delivered, move),
Returned: (returned) => fromReturned(returned, move),
});
Разберём.
Data.TaggedEnum.Value<Letter, 'Accepted'>вырезает из объединения одно состояние. ФункцияfromAcceptedне может по ошибке получить доставленное письмо: TypeScript не пустит.$matchтребует все теги, какMatch.exhaustive. Добавишь состояниеHeld, иstep.tsперестанет собираться, пока не появитсяheld.ts. Внутри файла состоянияMove.$matchтак же требует все ходы.
Почему Result, а не Effect
step чистая: ни сети, ни времени, ни сервисов. Возвращает она Result, обычное значение: Result.succeed(письмо) или Result.fail(new IllegalMove(...)). Его проверяют в тесте без runPromise и складывают в цепочку Result.flatMap. Внутри эффекта ответ поднимают одной функцией:
const next = yield* Effect.fromResult(step(letter, move));
Успех даёт письмо, провал уходит в канал ошибок как IllegalMove. Правила остаются чистыми, а журнал, который пишет в базу, живёт в сервисе слайса.
Своя башня
Вторая беда журнала тише. Горожане пишут коды башен как придётся: t-02, T-2, T-002. Ринцвальд считал нагрузку в Map<string, number>, и у него вышло две башни T-2: с четырьмя письмами и с одним. Нормализовать строку можно, но про нормализацию должен помнить каждый, кто кладёт ключ в словарь. Кто-нибудь забудет.
Надёжнее сделать башню своим типом, у которого равенство значит то, что нужно линии. Пусть башня ещё и помнит, как её написал горожанин: это пригодится для ответа «вы писали t-02, это T-2».
import { Equal, Hash, Pipeable } from 'effect';
export class TowerId extends Pipeable.Class implements Equal.Equal {
constructor(
readonly number: number,
readonly written: string,
) {
super();
}
[Equal.symbol](that: unknown): boolean {
return that instanceof TowerId && that.number === this.number;
}
[Hash.symbol](): number {
return Hash.number(this.number);
}
toString(): string {
return `T-${this.number}`;
}
}
Три детали, и у каждой своя работа.
[Equal.symbol]решает, что две башни равны, если равны номера. Без него Effect 4 сравнил бы экземпляры по всем полям, как простые объекты из урока 44, иwrittenсделал быt-02иT-2разными башнями.[Hash.symbol]считает хеш из того же номера. Хеш обязан смотреть ровно на то, на что смотрит равенство, и ни на что больше. Почему так строго, расскажет Пондер.Pipeable.Classдаёт методpipe, как у всех типов Effect:tower.pipe(String)даёт'T-2'.
Теперь коллекции Effect видят одну башню:
const seen = HashSet.make(new TowerId(2, 't-02'), new TowerId(2, 'T-2'), new TowerId(3, 'T-3'));
HashSet.size(seen); // 2
HashSet.has(seen, new TowerId(2, ' T-002')); // true
const load = HashMap.make([new TowerId(2, 't-02'), 4]);
HashMap.get(load, new TowerId(2, 'T-2')); // Option.some(4)
Машина состояний получает петли бесплатно: путь письма в InTransit это HashSet<TowerId>, и письмо, которое возвращается на t-02 после T-2, HashSet.has узнаёт.
Под капотом
Разминка
В src/tower-id.ts напиши TowerId.parse для кода в любом написании и равенство с хешем по номеру, в src/letter.ts step по таблице ходов из README (с петлёй через HashSet) и loadByTower.
Практика
Глубже в архиве
Порты между ядром, хранилищем и сетью разобраны в уроке про гексагон, значения и агрегаты в уроке про DDD без фанатизма курса Python-бэкенда. Язык другой, мысль та же: правило живёт рядом с данными.
Дальше
К вечеру журнал линии переписан. Пондер пробует записать доставку после возврата и получает IllegalMove: «письмо уже вернулось». В дверь заглядывает почтмейстер.
Как жить со старым кодом, не переписывая его, разберём в следующем уроке.