Тактические решения: автомат, состояние, ошибки, границы
В уроке про принципы мы разобрали архитектуру на макроуровне: как режем систему, как называем части, как связываем модули. Но те же три вопроса возвращаются на уровне кода, внутри одного модуля и одного файла. И там они принимают форму четырёх повторяющихся решений, которые ты принимаешь каждую неделю:
- Как представить жизненный цикл сущности: конечный автомат.
- Кто владеет изменяемым состоянием: shared mutable state.
- Куда девать ошибки: обработка ошибок.
- Кто кого видит: связи модулей и видимость.
Если в твоём проекте живёт тип с флагами isLoading, isError и isSuccess, первое из этих решений ты уже принял, просто не заметил этого.
У каждого решения есть два-три рабочих способа, и ни один не выигрывает всегда. Зато есть единый критерий выбора, и с него мы начнём.
Критерий: тест через год
Представь разработчика, который открывает твой код через год. Это можешь быть ты сам: за год контекст выветривается полностью, свой код читается как чужой. Вопрос теста: сможет ли читатель восстановить поведение из типов и сигнатур, не раскапывая тела функций и не спрашивая автора?
У теста есть измеримые прокси:
- Исчерпываемость: добавление нового случая ломает компиляцию, а не прод.
- Grep-ability: у сущности одно имя, поиск по нему находит все места использования.
- Локальность: изменение одного поведения живёт в одном файле, а не размазано по пяти.
- Инвариант в типе: правило вида «оплаченный заказ имеет transaction_id» видно в типе, а не в комментарии.
Дальше каждое из четырёх решений мы прогоняем через этот тест. Спойлер: почти всегда выигрывает способ, при котором незнание наказывается ошибкой компиляции, а не инцидентом.
Решение 1 · Конечный автомат
Сущность с жизненным циклом (заказ, платёж, соединение, загрузка файла) это конечный автомат, даже если ты не называешь её так. Вопрос не в том, есть ли автомат, а в том, явный он или размазан по if-ам. Три способа сделать его явным.
Способ 1 · Discriminated union + exhaustive match
Состояние как тип-сумма, переход как чистая функция от состояния и события:
type OrderState =
| { tag: 'draft' }
| { tag: 'placed'; placedAt: Date }
| { tag: 'paid'; placedAt: Date; transactionId: string }
| { tag: 'shipped'; placedAt: Date; transactionId: string; trackingId: string };
type OrderEvent =
| { tag: 'place' }
| { tag: 'pay'; transactionId: string }
| { tag: 'ship'; trackingId: string };
function transition(state: OrderState, event: OrderEvent): OrderState {
switch (state.tag) {
case 'draft':
return event.tag === 'place' ? { tag: 'placed', placedAt: new Date() } : state;
case 'placed':
return event.tag === 'pay'
? { ...state, tag: 'paid', transactionId: event.transactionId }
: state;
case 'paid':
return event.tag === 'ship'
? { ...state, tag: 'shipped', trackingId: event.trackingId }
: state;
case 'shipped':
return state;
}
}
Обрати внимание на форму типа: transactionId существует только начиная с состояния paid. Невозможно создать отправленный заказ без оплаты, компилятор не даст. Это принцип make illegal states unrepresentable.
Пощупай разницу руками. В режиме union поля появляются вместе с состоянием, а невозможный заказ нельзя даже собрать. В режиме флагов те же четыре состояния превращаются в шестнадцать комбинаций, и от двенадцати невозможных тебя не защищает никто:
Когда применять: по умолчанию. Доменные сущности, парсеры, UI-флоу, протоколы. Это тот же приём, что в Decider из урока про FP и DDD, только без event sourcing. Подробный разбор техники сужения по тегу: 04-ts · мост и странности.
Тест через год: лучший результат из трёх. Новое состояние добавил, компилятор подсветил каждый необработанный switch. Читатель видит весь жизненный цикл в одном типе.
У этого семейства приёмов есть академическое имя: typestate, программирование, ориентированное на состояния-типы.
transactionId, который существует только начиная сpaid, это ровно оно: доступные поля и операции зависят от текущего состояния, а неверный порядок переходов не компилируется. На Haskell тот же приём, вплоть до примера с открытым и закрытым файлом, разобран в моделировании домена через ADT.
Способ 2 · Таблица переходов
Переходы как данные, а не как код:
const transitions: Record<string, Record<string, string>> = {
draft: { place: 'placed' },
placed: { pay: 'paid', cancel: 'cancelled' },
paid: { ship: 'shipped', refund: 'refunded' },
};
function next(state: string, event: string): string | undefined {
return transitions[state]?.[event];
}
Это ровно приём из урока описание против интерпретации: таблица описывает автомат, маленький интерпретатор его исполняет. Описание можно отрисовать в диаграмму, провалидировать скриптом, загрузить из конфига, показать аналитику.
Когда применять: состояний много (десяток и больше), переходы однотипные, диаграмму нужно ревьюить вместе с не-программистами или генерировать из неё документацию. Diff таблицы в PR читается за секунды, diff switch-а требует вчитываться.
Цена: действия и guard-условия в таблицу ложатся плохо, типизация слабее (состояние превращается в строку), инварианты данных (тот же transactionId) из таблицы не видны.
Способ 3 · Statechart-библиотека или актор
Когда автомату нужны вложенные состояния, параллельные регионы, таймеры и история, плоский union взрывается комбинаторно. Для этого Дэвид Харел придумал statecharts, а в JS-мире их реализует XState. Родственный вариант: автомат живёт внутри актора, состояние на процесс, как в Erlang gen_statem (про акторы ниже и в 05-async · контекст и эффекты).
Когда применять: длинные оркестрации (онбординг на 12 шагов, медиаплеер, drag-and-drop), сценарии, где нужна визуализация для команды. Платишь зависимостью и кривой входа, окупается только на действительно сложных машинах.
Чего избегать
Классический ООП State-паттерн, класс на каждое состояние с полиморфным методом, проигрывает всем трём способам тест через год: логика одного перехода размазана по файлам, а полного списка состояний нет нигде, кроме твоей головы. И худший вариант, неявный автомат: четыре булевых флага isLoading, isError, isSuccess, isCancelled дают шестнадцать комбинаций, из которых валидны четыре. Остальные двенадцать живут в проде и ждут своего часа.
Решение 2 · Shared mutable state
Изменяемое состояние, которое читают и пишут несколько частей программы, это самый дорогой источник багов: гонки, рассинхрон, чтение наполовину обновлённых данных. Способы расставлены от дефолта к крайней мере.
Способ 1 · Единственный владелец + immutable наружу
Состоянием владеет ровно одно место. Наружу уходят снапшоты или иммутабельные копии, внутрь приходят запросы на изменение:
function makeCatalog() {
let snapshot: ReadonlyMap<string, Flight> = new Map();
return {
read: () => snapshot,
refresh: async () => {
const fresh = await loadFlights();
snapshot = new Map(fresh.map((f) => [f.id, f]));
},
};
}
Читатели держат ссылку на иммутабельный снапшот, и даже если refresh подменит его посреди чтения, читатель видит консистентную версию, старую или новую, но не смесь. Гонки исчезают не потому, что мы их защитили, а потому что им негде возникнуть. Лозунг из Go-мира: share memory by communicating, не наоборот.
Когда применять: по умолчанию, всегда, когда архитектура позволяет. Подробнее про оси владения состоянием: 05-async · оси и потоки.
Способ 2 · Сериализация доступа: актор, очередь, один поток-владелец
Если изменений много и они должны идти в строгом порядке, состояние закрывается в актора: приватное состояние плюс очередь сообщений, обрабатываемых по одному. Конкуренция исчезает по построению: внутри актора всегда один поток исполнения.
В JS этот способ встроен в платформу: event loop сериализует все обращения к состоянию модуля, воркер с postMessage это готовый актор. В Go та же форма: горутина-владелец, остальные пишут в канал. Разбор Go-паттернов: 22-golang · паттерны конкурентности.
Когда применять: порядок операций важен, логика мутации нетривиальная (проверки, инварианты, побочные эффекты), писателей несколько. Цена: задержка очереди и опасность переполнения почтового ящика, нужен back-pressure.
Способ 3 · Примитивы синхронизации: mutex, RWLock, atomic
Защищаем сами данные: перед доступом бери лок, после отпусти.
Когда применять: горячий путь, где очередь актора слишком дорога; состояние маленькое и паттерн доступа примитивный (счётчик, кэш с TTL, флаг). В JS почти не встречается (однопоточная модель), в Go и Rust это sync.Mutex и RwLock.
Цена: дисциплина не видна в типах. Правило «перед чтением возьми лок» живёт в голове команды и в код-ревью, а не в компиляторе. Забытый лок, дедлок из двух локов в разном порядке, лок, который держат через await: всё это ошибки, которые читатель через год не увидит в сигнатуре. Rust здесь исключение, его Mutex<T> не даёт добраться до данных без захвата, но в большинстве языков лок и данные связаны только договорённостью.
Тест через год: ownership побеждает, актор второй, локи последние, строго по убыванию того, сколько правил надо удержать в голове.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.