Моделирование: домен, контракты и конечные автоматы
В уроке 06 мы научились делать невозможные состояния непредставимыми: sum-типы вместо пачки опциональных полей, parse вместо validate. Это статика, снимок данных в один момент времени. Сегодня добавляем время: как сущность живёт, какие события её меняют и какие изменения вообще законны. Для этого нам понадобятся три инструмента, и все три про одно и то же, про явные модели вместо неявных договорённостей:
- модель домена: словарь, на котором говорят код и бизнес;
- контракты: правила, которые код обещает соблюдать;
- конечные автоматы: жизненный цикл сущности, выписанный явно.
Начнём с привычной боли. В любом проекте старше полугода есть сущность со статусным полем:
type Order = {
id: string;
status: 'draft' | 'paid' | 'assembly' | 'shipping' | 'delivered' | 'cancelled';
// ещё тридцать полей
};
И есть код, который этим статусом управляет:
async function shipOrder(order: Order) {
if (order.status === 'cancelled') {
throw new Error('заказ отменён');
}
// а из draft отправлять можно? а из shipping повторно?
order.status = 'shipping';
await ordersRepo.save(order);
}
Проверки размазаны по сервисам, каждая функция помнит свой кусочек
правил, и однажды появляется заказ, который уехал в доставку, не будучи
оплаченным. Поле status со строковым литералом это автомат, который
никто не выписал явно. Правила переходов существуют, но живут в головах
и в десятке if-ов. Этот урок про то, как вытащить их наружу.
Модель: зачем упрощать реальность
Модель, это не уменьшенная копия реальности, а упрощение под задачу. Карта метро не показывает реальные расстояния и повороты тоннелей, и именно поэтому ей удобно пользоваться. Хорошая модель определяется не полнотой, а тем, что она отбрасывает.
Один и тот же заказ выглядит по-разному из разных отделов:
- для склада заказ это список позиций, вес и ячейка хранения;
- для бухгалтерии: сумма, НДС, статус оплаты и счёт-фактура;
- для доставки: адрес, временное окно и телефон получателя.
Попытка построить одну универсальную модель заказа для всех сразу даёт объект на восемьдесят полей, половина которых в каждом сценарии пуста. DDD называет границу, внутри которой модель целостна и непротиворечива, bounded context. Внутри одного контекста слово значит ровно одно. Заказ склада и заказ бухгалтерии связаны общим идентификатором, но это разные модели.
Второй столп: ubiquitous language,
единый язык. Если менеджер говорит «отгрузка», а в коде написано
dispatch в одном модуле и shipment в другом, каждый разговор о баге
начинается с синхронизации словарей. Правило простое: слова из
разговоров с бизнесом должны дословно совпадать со словами в коде.
Имена состояний заказа из примера выше и есть кусочек такого словаря.
Чтобы модель не растворилась в техническом коде, её отделяют слоями:
- domain model: сущности и правила домена, без SQL и HTTP;
- service layer: сценарии использования, оркестрация доменных объектов;
- repository: граница между доменом и хранилищем, наружу отдаёт доменные объекты, внутри прячет SQL.
Это минимальный набор, который стоит знать уже сейчас. Полный разбор DDD: агрегаты, доменные события и прочее ждет тебя в уроке про DDD бэкенд-раздела. Для сегодняшней темы важно одно: модель фиксирует словарь и правила. А дальше встаёт вопрос, как заставить код эти правила соблюдать.
Контракты: правила, которые код обещает соблюдать
Идея стара и формальна. Ещё в 1969 году Тони Хоар предложил описывать
смысл программы
тройками
вида {P} S {Q}: если до выполнения кода S истинно предусловие P,
после выполнения истинно постусловие Q. Бертран Мейер в языке Eiffel
превратил это в практику под именем
design by contract:
у каждого метода есть предусловия, постусловия и инварианты.
- Предусловие: что обязан обеспечить вызывающий. Снять деньги можно только с открытого счёта.
- Постусловие: что гарантирует метод. После снятия баланс уменьшился ровно на сумму операции.
- Инвариант: что истинно всегда, между любыми вызовами. Баланс счёта не бывает отрицательным.
В TypeScript нет встроенных контрактов, но по духу мы их уже пишем:
import { z } from 'zod';
const Withdrawal = z.object({
accountId: z.string().uuid(),
amount: z.number().positive(), // предусловие на данных
});
function assertCanWithdraw(account: Account, amount: number): void {
// предусловие на состоянии
if (account.status !== 'open') {
throw new Error('счёт не открыт');
}
if (account.balance < amount) {
throw new Error('недостаточно средств');
}
}
Валидация zod на границе системы из урока 06,
это контракт на данных: дальше границы невалидное значение не проходит.
asserts-функции и branded-типы переносят часть контракта в типы.
Остальное проверяем в рантайме и падаем сразу: нарушенный контракт это
баг, маскировать его значит дать ошибке уйти глубже.
Сюда же примыкает
закон Деметры:
не разговаривай с незнакомцами. Код вида
order.getCustomer().getWallet().charge(100) знает о внутренностях трёх
чужих объектов, и любое их изменение его ломает. Контракт должен жить у
владельца данных: order.chargeCustomer(100). Тогда и проверки
инвариантов собираются в одном месте, а не размазываются по вызывающим.
Проверки контрактов часто хочется вынести из основной логики: это сквозная функциональность, как логирование или транзакции. В ООП-мире для этого придумали AOP; в TypeScript эту роль играют декораторы и Proxy из урока про метапрограммирование.
Теперь главный мостик урока. Посмотри ещё раз на shipOrder из начала.
Какой там контракт? Предусловие «заказ оплачен и собран», постусловие
«заказ в доставке». А инвариант сущности целиком звучит так: статус
меняется только по разрешённым переходам. Заказ не телепортируется из
черновика в доставленные. Такой инвариант неудобно выражать россыпью
if-ов, зато для него есть готовая математика. Встречай: конечный
автомат.
Конечный автомат: формализм
Конечный автомат описывается пятёркой: конечное множество состояний, конечный алфавит входных событий, функция переходов, начальное состояние и, если автомат распознаёт язык, множество принимающих состояний. В каждый момент автомат находится ровно в одном состоянии, и это его единственная память.
Формально конечные автоматы принадлежат классу конечных динамических
систем. Состояние системы в такт t описывается конечным числом
обобщённых координат Y(t), на вход поступают воздействия X(t).
Система называется конечным автоматом, если новое состояние однозначно
определяется предыдущим состоянием и входом:
Y(t) = f(X(t), Y(t-1)); автомат первого рода
Y(t) = f(X(t-1), Y(t-1)); автомат второго рода
Автомат первого рода реагирует на вход в текущем такте, второго рода на вход предыдущего такта. Для практики важна сама форма записи: поведение задаётся рекуррентным соотношением, новое состояние это функция от старого состояния и события. Ничего больше. Ни истории всех прошлых событий, ни внешнего контекста: всё, что автомату нужно помнить, обязано быть закодировано в его состоянии.
По способу формирования выхода автоматы делят на два семейства:
автомат Мили
вычисляет выход из состояния и входа, выход висит на рёбрах;
автомат Мура
вычисляет выход только из состояния, выход висит на вершинах. В коде
эта разница превращается в вопрос, куда вешать побочные действия: на
переход (отправить письмо при переходе в paid) или на состояние
(пока заказ в shipping, показываем трекинг). Реальные библиотеки
дают и то и другое.
Чем автомат отличается от машины Тьюринга
В уроке про парсеры конечный автомат уже появлялся как распознаватель регулярных языков, нижнего этажа иерархии Хомского. Машина Тьюринга стоит на верхнем этаже, и разница между ними поучительна. У машины Тьюринга есть бесконечная лента, и её полное состояние это управляющий автомат плюс содержимое ленты. Состояния управляющего автомата определяют действия машины, а состояние ленты лишь результат этих действий. Первые называют управляющими состояниями, вторые вычислительными.
Это различие переезжает прямо в твой код. У сущности «заказ» есть
управляющее состояние, поле status с шестью значениями, и есть
вычислительное: адрес, позиции, суммы. Управляющих состояний конечное
и небольшое число, они определяют, какие операции сейчас законны.
Вычислительных состояний практически бесконечно много, но на логику
переходов они влияют только через условия. Конечный автомат моделирует
именно управляющую часть, и в этом его сила: шесть состояний можно
выписать, нарисовать и проверить перебором, миллиард комбинаций полей
нельзя.
В разных дисциплинах автоматы поворачиваются разными гранями. Теория формальных языков изучает абстрактные автоматы: внутренняя структура не важна, интересна только вычислительная мощность, класс языков, которые машина распознаёт. Логическое управление изучает структурные автоматы: они работают управляющими устройствами, важно число входов и выходов и связи с объектом управления. Наш сегодняшний ракурс ближе ко второму: автомат как ядро управления сущностью со сложным поведением.
Автоматное программирование: состояния, которые видно
Анатолий Шалыто, автор подхода, формулирует парадигму так: автоматное программирование состоит в представлении сущностей со сложным поведением в виде автоматизированных объектов управления. За тяжёлой формулировкой простая дисциплина мышления. В программировании с явным выделением состояний главный вопрос звучит так: в каких состояниях может находиться система?
Не «какие у нас флаги», не «какие поля в таблице», а именно состояния: качественно разные режимы поведения, в которых система реагирует на одни и те же события по-разному. Слово «режим» здесь ключевое. Если в описании системы встречаются режимы (плеер: играет, на паузе, буферизует; редактор: просмотр, правка, конфликт), каждому режиму почти наверняка соответствует управляющее состояние.
Посмотрим, как выглядит код, в котором состояния не выделены. Классика фронтенда и не только:
type RequestStore = {
isLoading: boolean;
isError: boolean;
data: User[] | null;
error: string | null;
};
Два булевых флага дают четыре комбинации, плюс независимые data и
error. Что значит isLoading: true вместе с isError: true? А
isError: true при error: null? Эти комбинации не значат ничего, но
код обязан их обрабатывать или молча надеяться, что они не возникнут.
Каждый новый флаг удваивает число комбинаций, и доля мусорных растёт.
Лечение ты уже знаешь из урока 06: discriminated union.
type RequestState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'resolved'; data: User[] }
| { kind: 'failed'; error: string };
Свежий взгляд на этот тип: поле kind это управляющее состояние,
полезная нагрузка рядом с ним это вычислительное состояние, а сам тип
это множество состояний конечного автомата. Осталось дописать вторую
половину автомата, которую тип не выражает: какие переходы разрешены.
Из idle можно в loading, из loading в resolved или failed, а
вот из resolved сразу в failed нельзя. Sum-тип запрещает
бессмысленные снимки, автомат запрещает бессмысленные траектории.
Метод проектирования «от объектов управления и событий» из автоматного программирования раскладывается в четыре шага:
- Зафиксируй не только словесное описание поведения, но и точный список событий из внешней среды плюс запросы и команды всех объектов управления.
- Построй набор управляющих состояний. Ищи режимы и ситуации, в которых поведение качественно разное.
- Каждому запросу объекта управления сопоставь входную переменную автомата, каждой команде выходную.
- Построй автомат: таблицу переходов на состояниях, событиях, входных и выходных переменных.
Особенно хорошо метод ложится на событийные системы, где входные воздействия и так приходят событиями: UI, очереди сообщений, вебхуки, протоколы. Там автомат не надстройка, а честная форма того, что и так происходит. Кстати, ты уже видел этот приём изнутри платформы: async/await в JS компилируется в конечный автомат по точкам await, мы разбирали это в уроке про машинерию. И паттерн State из набора GoF, это тот же автомат, переложенный на классы: состояние-объект, переходы как замена объекта.
Sum-тип ты уже умеешь писать, а вот таблицу переходов к нему стоит собрать руками: она и есть та половина автомата, которую тип не выражает.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.