В начале разработки система заявок почти всегда выглядит простой.
Появляется запись:
Заявка №142
Клиент: Иван
Тема: разработка CRM
Статус: НоваяАдминистратор открывает её и меняет статус:
Новая → В работеПозже:
В работе → ЗавершенаКажется, что достаточно добавить в базу поле:
statusи выпадающий список в административной панели.
Проблемы начинаются немного позже.
Что произойдёт, если клиент должен сначала ответить на уточняющий вопрос?
Можно ли завершить заявку, которая ещё ожидает оценки?
Можно ли вернуть завершённую заявку обратно в «Новую»?
Что делать, если клиент отказался?
Чем «Отменена» отличается от «Завершена»?
Можно ли удалить статус, если им уже пользуются тысячи записей?
Может ли клиент самостоятельно перевести заявку в работу?
И что произойдёт, если два администратора одновременно откроют одну заявку и изменят её состояние?
В этот момент становится понятно: статус — не подпись возле заявки. Это состояние бизнес-процесса.
А если есть состояния, значит существуют допустимые и запрещённые переходы между ними.
Именно здесь обычный status начинает превращаться в state machine — конечный автомат, описывающий жизненный цикл сущности.
Разберём, как проектировать такую модель на примере заявки в сложном веб-продукте.
Почему обычного списка статусов недостаточно
Представим набор:
Новая
Требуется уточнение
Оценка
Принята в работу
В работе
Завершена
ОтмененаЕсли реализовать его обычным select, администратор сможет выбрать любое значение:
Новая
↓
Завершенаили:
Отменена
↓
В работеили:
Завершена
↓
Требуется уточнениеС точки зрения базы данных всё корректно.
Строка содержит допустимое значение enum.
С точки зрения бизнеса часть этих состояний может быть невозможна.
В этом и заключается фундаментальная разница между:
списком допустимых значенийи:
жизненным циклом объектаEnum отвечает:
Какие состояния существуют?
State machine:
Из какого состояния в какое можно перейти, при каких условиях и кто имеет право это сделать?
Сначала нужно описать сам процесс, а не интерфейс
Плохая точка старта:
Какие пункты добавить в выпадающий список?
Хорошая:
Что в реальности происходит с заявкой от момента появления до завершения?
Например, процесс может выглядеть так:
Создана
↓
Новая
↓
┌─────────────────────┐
│ │
↓ ↓
Уточнение Оценка
│ │
└──────────┬──────────┘
↓
Согласование
↓
Принята в работу
↓
В работе
↓
ЗавершенаПри этом практически на каждом этапе может существовать отдельная ветка:
ОтмененаНо даже это ещё слишком упрощённая схема.
Например, отменить уже завершённую заявку, возможно, нельзя.
А заявка, ожидающая ответа клиента, должна вести себя иначе, чем заявка, которую сейчас анализирует менеджер.
Поэтому полезно перестать думать о статусе как о декоративной метке.
Состояние должно отвечать на вопрос: что сейчас происходит?
Хорошее название статуса несёт операционный смысл.
Например:
Новаяозначает:
Заявка создана, но сотрудник ещё не начал её обработку.
Требуется уточнениеозначает:
Продолжение процесса заблокировано до получения информации.
Оценкаозначает:
Исполнитель анализирует объём и формирует условия.
Принята в работуозначает:
Условия согласованы, работу можно начинать.
Завершенаозначает:
Нормальный жизненный цикл закончился успешно.
Статус становится полезным, когда из него можно понять не только прошлое, но и допустимое следующее действие.
Иногда один статус скрывает несколько разных состояний
Представим:
ОжиданиеНо чего именно?
Ожидаем ответа клиентаили:
Ожидаем решения администратораили:
Ожидаем оплатуили:
Ожидаем документыДля пользователя это разные ситуации.
Для автоматизации — тоже.
В первом случае нужно напомнить клиенту.
Во втором — менеджеру.
В третьем — проверить платёж.
Поэтому слишком общие статусы часто приводят к появлению дополнительных полей:
status = waiting
waitingFor = clientИногда это нормальная модель.
Но если вариантов немного и они имеют самостоятельную бизнес-семантику, понятнее использовать отдельные состояния:
WAITING_CLIENT
WAITING_PAYMENT
WAITING_ADMINГлавное — чтобы state model отражала реальный процесс, а не стремилась иметь минимально возможное количество строк.
Опишем переходы явно
Предположим, существует состояние:
NEWИз него разрешены:
NEW → NEEDS_CLARIFICATION
NEW → ESTIMATION
NEW → CANCELLEDНо не:
NEW → COMPLETEDСледующий статус:
NEEDS_CLARIFICATIONможет иметь переходы:
NEEDS_CLARIFICATION → ESTIMATION
NEEDS_CLARIFICATION → CANCELLEDESTIMATION:
ESTIMATION → NEEDS_CLARIFICATION
ESTIMATION → OFFER_SENT
ESTIMATION → CANCELLEDПосле подтверждения условий:
OFFER_SENT → ACCEPTEDа далее:
ACCEPTED → IN_PROGRESS
IN_PROGRESS → COMPLETEDПолучилась настоящая модель.
Теперь система знает не только список состояний, но и граф переходов.
Почему это лучше, чем проверять всё по месту
Без state machine код со временем начинает выглядеть так:
if (
status !== 'completed' &&
status !== 'cancelled' &&
status !== 'archived'
) {
...
}В другом endpoint:
if (
status === 'new' ||
status === 'estimate' ||
status === 'clarification'
) {
...
}В третьем — ещё одна комбинация.
Через несколько месяцев никто точно не знает, какие переходы действительно разрешены.
State machine позволяет определить правила централизованно.
Например:
const transitions = {
NEW: [
'NEEDS_CLARIFICATION',
'ESTIMATION',
'CANCELLED'
],
NEEDS_CLARIFICATION: [
'ESTIMATION',
'CANCELLED'
],
ESTIMATION: [
'NEEDS_CLARIFICATION',
'OFFER_SENT',
'CANCELLED'
],
OFFER_SENT: [
'ACCEPTED',
'NEEDS_CLARIFICATION',
'CANCELLED'
],
ACCEPTED: [
'IN_PROGRESS'
],
IN_PROGRESS: [
'COMPLETED',
'SUSPENDED',
'CANCELLED'
]
};Теперь проверка становится понятной:
canTransition(
currentStatus,
nextStatus
)Но одного графа недостаточно
Реальный бизнес-переход часто имеет дополнительные условия.
Например:
OFFER_SENT → ACCEPTEDразрешён только если клиент действительно подтвердил предложение.
ACCEPTED → IN_PROGRESSможет требовать:
ТЗ подтверждено
КП подтверждено
стартовый платёж полученА:
IN_PROGRESS → COMPLETEDможет быть разрешён только после заполнения результата.
Получается:
Transition
+
Guard
=
разрешённое изменение состоянияGuard — правило, которое должно быть истинным
Например:
function canStartProject(project) {
return (
project.specApproved === true &&
project.offerApproved === true &&
project.initialPaymentConfirmed === true
);
}Тогда недостаточно сказать:
текущий статус = ACCEPTEDНужно ещё проверить бизнес-инварианты.
Это защищает систему от состояния:
В работепри отсутствии утверждённого ТЗ.
Именно здесь state machine начинает приносить реальную пользу
Без неё данные могут постепенно приходить к комбинациям:
status = IN_PROGRESS
specApproved = falseили:
status = COMPLETED
resultDelivered = falseили:
status = CANCELLED
activeWorker = trueКаждое поле по отдельности корректно.
Их комбинация — нет.
Такие состояния особенно неприятны, потому что база не выглядит повреждённой.
Но бизнес-процесс уже противоречит сам себе.
Инварианты важнее красивых статусов
Допустим, правило бизнеса:
Нельзя запустить проект до подтверждения ТЗ.
Это должно быть не подсказкой в интерфейсе.
А инвариантом backend.
То есть такой запрос:
POST /projects/1842/startпри:
specApproved = falseдолжен быть отклонён сервером независимо от того, какую кнопку показывает frontend.
Именно это отличает бизнес-правило от UX.
Не давайте клиенту напрямую менять status
Плохой API:
PATCH /requests/142{
"status": "COMPLETED"
}Frontend отправляет произвольное новое значение.
Такой API постепенно превращает state machine обратно в обычный enum.
Лучше моделировать действия:
requestClarification()
submitClarification()
sendOffer()
acceptOffer()
startWork()
completeWork()
cancelRequest()То есть клиент говорит системе:
Я подтверждаю предложение.
а не:
Установи поле status = ACCEPTED.Разница очень важна.
Команда выражает намерение, статус — результат
Например:
Command:
acceptOffer()Backend выполняет:
проверить пользователя;
проверить текущий статус;
проверить, что предложение актуально;
зафиксировать подтверждение;
сменить состояние;
записать событие;
отправить уведомления.В конце:
status = ACCEPTEDТо есть статус является результатом успешной бизнес-операции.
Не входным параметром, которому сервер слепо доверяет.
Это особенно полезно для API
Вместо:
PATCH /request
status = ...можно иметь:
POST /requests/:id/request-clarification
POST /requests/:id/accept-offer
POST /requests/:id/start
POST /requests/:id/complete
POST /requests/:id/cancelТак API сам документирует жизненный цикл.
Потребителю значительно труднее случайно создать невозможное состояние.
Не каждый переход доступен каждому участнику
State machine отвечает:
Можно ли перейти?
Authorization:
Кто имеет право инициировать переход?
Например:
NEW → CANCELLEDможет быть доступен клиенту и администратору.
NEW → ESTIMATIONтолько администратору.
OFFER_SENT → ACCEPTEDтолько клиенту.
IN_PROGRESS → COMPLETEDтолько исполнителю.
Получается правило:
Current state
+
Requested transition
+
Actor
+
Business conditions
=
DecisionРоль сама по себе тоже бывает недостаточна
Например:
ADMINможет переводить проект в IN_PROGRESS.
Но только если он относится к этому проекту.
Или:
CLIENTможет подтвердить КП.
Но только клиент, которому принадлежит конкретная заявка.
Поэтому state machine должна работать вместе с:
permissions;
ownership;
tenant isolation.Она не заменяет authorization.
Frontend должен получать допустимые действия от backend
Есть два подхода.
Первый — продублировать всю state machine во frontend.
Например:
if (status === 'OFFER_SENT') {
showAcceptButton();
}Для простого приложения это возможно.
Но затем появляются дополнительные guards.
Например, предложение истекло.
Backend уже запрещает acceptance.
Frontend об этом не знает.
Хороший вариант — backend возвращает capabilities:
{
"status": "OFFER_SENT",
"actions": {
"accept": true,
"requestClarification": true,
"cancel": true,
"start": false
}
}Frontend просто отображает доступные действия.
Backend всё равно повторно проверяет их при запросе.
Почему capabilities удобнее статусов
Представим frontend-разработчика.
Ему не обязательно знать:
из OFFER_SENT можно перейти
в ACCEPTED только если offer.version
совпадает с approvedVersion.Ему достаточно:
canAccept = trueЭто уменьшает дублирование бизнес-правил между backend и frontend.
Один и тот же статус может выглядеть по-разному для разных ролей
Например:
NEEDS_CLARIFICATIONДля администратора:
Ожидаем ответ клиента.
Для клиента:
Требуется ваше уточнение.
То есть внутреннее состояние одно.
Но presentation layer разный.
Это лучше, чем создавать:
WAITING_CLIENTи:
CLIENT_NEEDS_TO_REPLYесли они описывают один и тот же бизнес-факт только с разных точек зрения.
Не нужно смешивать state и label
В базе:
NEEDS_CLARIFICATIONВ интерфейсе клиента:
Требуется уточнение.
В административной панели:
Ожидается ответ клиента.
В email:
Пожалуйста, уточните требования по заявке №142.
State code должен быть стабильным.
Текст можно изменять независимо.
Почему системный код статуса не стоит редактировать из админки
Представим:
IN_PROGRESSиспользуется в:
backend;
analytics;
notifications;
tests;
state machine.Администратор переименовал код в:
WORKINGи часть приложения перестала понимать состояние.
Поэтому нужно различать:
code = IN_PROGRESSи:
label = В работеLabel можно редактировать.
Code обычно является частью программного контракта.
Удалять использованный статус тоже опасно
Допустим, бизнес больше не использует:
SUSPENDEDВ базе уже есть 800 старых проектов с таким статусом.
Физически удалить значение нельзя без миграции истории.
Практичнее:
is_active = falseТеперь старые записи продолжают корректно отображаться.
Новые больше не могут попасть в это состояние.
Это особенно важно для долгоживущих продуктов.
Терминальное состояние — это не обязательно «навсегда»
Обычно:
COMPLETED
CANCELLEDсчитаются terminal.
Но бизнес должен заранее решить:
Можно ли восстановить ошибочно закрытую заявку?
Если да, лучше иметь явную операцию:
reopen()и строго определить:
COMPLETED → REOPENEDили:
COMPLETED → IN_PROGRESSЕсли возврат разрешён просто через произвольное изменение dropdown, история становится непонятной.
«Вернуть назад» часто не означает обратный переход
Представим:
OFFER_SENT
↓
ACCEPTEDКлиент уже подтвердил КП.
Затем условия изменились.
Возвращать заявку:
ACCEPTED → OFFER_SENTможет быть неправильно.
Потому что старое предложение уже было принято.
Возможно, бизнес-операция должна создать:
CHANGE_REQUESTили новую версию предложения.
Это важное свойство state machine:
история процесса не всегда симметрична.
Граф переходов почти никогда не двунаправленный
Если допустимо:
A → Bэто не означает:
B → AНапример:
Новая → Отмененанормально.
Отменена → Новаяуже требует отдельного смысла:
Что значит восстановить отменённую заявку?
Лучше определить его явно, чем автоматически считать каждый переход обратимым.
Cancel и Delete — разные вещи
Если клиент отказался от заявки, это не значит, что запись нужно удалить.
CANCELLEDявляется частью истории.
Она может содержать:
кто отменил;
когда;
почему;
на каком этапе.Физическое удаление уничтожило бы контекст.
Для бизнес-систем обычно полезнее:
soft stateчем:
DELETE FROM requestsПричина перехода тоже может быть частью модели
Например:
IN_PROGRESS → CANCELLEDМожно потребовать:
reasonВарианты:
клиент отказался;
невозможно выполнить;
дублирующая заявка;
условия не согласованы;
другая причина.Теперь аналитика способна отвечать:
Почему заявки чаще всего прекращаются?
State machine начинает работать не только для управления процессом, но и для анализа бизнеса.
Переход должен сохраняться как событие
Хранить только:
status = IN_PROGRESSнедостаточно.
Мы знаем состояние сейчас.
Но не знаем:
Как заявка сюда попала?
Полезна отдельная история:
RequestStatusHistory
request_id
from_status
to_status
actor_id
reason
created_atНапример:
09:14
NEW → NEEDS_CLARIFICATION
Администратор #4
11:43
NEEDS_CLARIFICATION → ESTIMATION
Клиент предоставил данные
15:18
ESTIMATION → OFFER_SENT
Администратор #4Это уже полноценный audit trail жизненного цикла.
История статусов и audit log похожи, но не одинаковы
Status history отвечает:
Как изменялось состояние заявки?
Audit log:
Какие действия вообще выполнялись?
Например:
Клиент добавил файлне обязательно меняет статус.
Но для расследования или поддержки событие важно.
В зрелой системе могут существовать оба слоя.
Дата текущего статуса тоже полезна
Например:
status = NEEDS_CLARIFICATION
statusChangedAt = ...Теперь можно искать:
Какие заявки ждут клиента больше трёх дней?
или:
Сколько в среднем занимает оценка?
State machine естественно превращается в основу операционной аналитики.
Можно рассчитывать время в каждом состоянии
Например:
NEW 12 мин
NEEDS_CLARIFICATION 2 дня
ESTIMATION 4 часа
OFFER_SENT 6 часов
IN_PROGRESS 11 днейТеперь бизнес понимает bottleneck.
Может оказаться, что сама разработка быстрая.
Но заявки по четыре дня лежат в «Оценке».
Без нормальной истории состояний увидеть это намного сложнее.
SLA удобно привязывать к state machine
Например:
NEW
→ реакция не позднее 2 часовNEEDS_CLARIFICATION
→ SLA исполнителя приостановленESTIMATION
→ до 24 часовЭто намного точнее общего:
Ответить на заявку за сутки.
Потому что процесс в некоторые периоды реально зависит не от исполнителя.
State machine помогает автоматизировать уведомления
Вместо:
где-нибудь после PATCH
отправить emailможно реагировать на переходы.
Например:
NEW → NEEDS_CLARIFICATIONсоздаёт:
notification → clientOFFER_SENTсоздаёт:
email → client
in-app notification → clientACCEPTEDсоздаёт:
notification → adminЛогика становится намного понятнее.
Но уведомление не должно определять успешность перехода
Представим:
ESTIMATION → OFFER_SENTсостояние сохранено успешно.
SMTP временно недоступен.
Нельзя откатывать всю заявку обратно только потому, что email не отправился.
Лучше:
сохранить transition
↓
создать notification job
↓
worker попробует отправить
↓
retry при ошибкеТак state machine не зависит от доступности внешней почты.
Side effects полезно выполнять после commit
Переход:
ACCEPTED → IN_PROGRESSможет запускать:
уведомление;
создание задачи;
webhook;
аналитику.Если начать их до сохранения состояния и одна операция упадёт, система может оказаться частично обновлённой.
Практичная модель:
validate transition
↓
database transaction
↓
commit
↓
enqueue side effectsВ более сложных системах для этого может использоваться outbox pattern.
State machine особенно полезна для webhook
Представим внешняя платёжная система дважды прислала:
payment_successЕсли callback просто делает:
status = PAIDповтор может вызвать побочные действия второй раз.
Если используется transition:
WAITING_PAYMENT → PAIDвторой webhook приходит, когда состояние уже:
PAIDСистема понимает:
Этот переход больше не требуется.
Это один из способов строить идемпотентную обработку событий.
Но одинаковый статус не всегда означает одинаковое событие
При повторном webhook всё равно лучше иметь:
eventIdи отдельную idempotency-защиту.
State machine — дополнительный барьер.
Не универсальная замена дедупликации.
Конкурентное изменение — ещё одна проблема
Представим заявку одновременно открыли два администратора.
Оба видят:
ESTIMATIONПервый отправляет:
ESTIMATION → OFFER_SENTВторой через секунду:
ESTIMATION → CANCELLEDЕсли backend просто выполняет последнюю запись:
CANCELLEDмы потеряли тот факт, что между чтением и записью состояние уже изменилось.
Переход должен проверять текущее состояние атомарно
В SQL это можно сделать примерно так:
UPDATE requests
SET status = 'OFFER_SENT'
WHERE id = $1
AND status = 'ESTIMATION';Если обновлена одна строка — переход применён.
Если ноль:
Состояние уже изменилось.
Теперь второй клиент должен перечитать заявку.
Это намного безопаснее:
прочитать
↓
проверить в Node.js
↓
через секунду UPDATEмежду которыми другой процесс способен внести изменение.
Можно использовать revision
Например:
revision = 17Клиент отправляет:
expectedRevision = 17UPDATE:
WHERE revision = 17после успешного перехода:
revision = 18Другой клиент с revision 17 получает conflict.
Так optimistic concurrency дополняет state machine.
HTTP 409 хорошо подходит для подобных конфликтов
Например:
409 Conflict{
"code": "STATE_CONFLICT",
"currentStatus": "OFFER_SENT"
}Frontend может показать:
Заявка уже была изменена другим пользователем. Данные обновлены.
Это намного лучше silent overwrite.
Не все невозможные состояния связаны только со status
Представим:
status = COMPLETED
deletedAt = nullнормально.
Но:
status = COMPLETED
completedAt = nullуже подозрительно.
Или:
status = CANCELLED
cancelledAt = nullПоэтому переход должен изменять связанные поля согласованно.
Например:
IN_PROGRESS → COMPLETEDв одной транзакции устанавливает:
status = COMPLETED
completedAt = now()
completedBy = userIdНе позволяйте API отдельно редактировать такие поля
Плохой вариант:
PATCH status
PATCH completedAt
PATCH completedByтремя разными запросами.
Между ними система находится в промежуточном состоянии.
Лучше одна бизнес-операция:
completeRequest()Database constraint тоже может быть полезен
Например:
CHECK (
status <> 'COMPLETED'
OR completed_at IS NOT NULL
)Backend уже контролирует правило.
Constraint даёт дополнительную защиту.
Особенно полезно кодировать в БД простые инварианты, которые никогда не должны нарушаться.
Но не нужно переносить всю state machine в CHECK
Если правила сложные, зависят от ролей, внешних данных и workflow, попытка полностью реализовать их SQL constraint быстро станет неудобной.
Практичный подход:
DB
→ простые фундаментальные ограничения
Domain layer
→ бизнес-переходы«Требуется уточнение» — интересный статус
Потому что он отражает зависимость процесса от внешнего участника.
Но даже здесь возможны разные сценарии.
Например, клиент прислал ответ.
Следующее состояние не обязательно автоматически:
ESTIMATIONВозможно:
ответ получен
↓
администратор проверяет
↓
ESTIMATIONНужно решить:
Является ли получение ответа отдельным state или событием?
Универсального ответа нет.
Не каждое событие заслуживает отдельного статуса
Если создать статус для каждой мелочи:
Файл загружен
Менеджер открыл
Ответ прочитан
Уведомление отправленоstate machine разрастается.
Полезный критерий:
меняет ли событие допустимое дальнейшее поведение заявки?
Если нет — скорее всего это event, а не state.
Например:
Клиент прикрепил файлможет быть событием.
Но:
Ожидаем обязательный файл клиентаможет быть состоянием, потому что процесс нельзя продолжить без него.
State отвечает за период времени, event — за факт
Это очень удобное различие.
OFFER_SENTможет длиться два дня.
Это state.
offer_openedпроизошло в 14:23.
Это event.
Если придерживаться этого принципа, модель становится значительно понятнее.
Не смешивайте состояние заявки и состояние оплаты
Это ещё одна распространённая ошибка.
Например:
REQUEST_STATUS =
WAITING_PAYMENTиногда оправдан.
Но если платёж имеет сложный собственный жизненный цикл:
created
pending
paid
failed
refundedвозможно, правильнее иметь две state machine:
Requestи:
Paymentа не пытаться создать все комбинации:
PROJECT_PAID
PROJECT_PAYMENT_FAILED
PROJECT_REFUNDED
...Композиция нескольких state machine часто лучше одной гигантской
Сложный продукт может иметь:
RequestState
PaymentState
ProjectState
DeliveryStateОни связаны правилами.
Например:
Payment = PAIDразрешает:
Project:
ACCEPTED → IN_PROGRESSНо это не значит, что payment status обязан быть частью ProjectStatus.
Так модель остаётся управляемой.
То же относится к документам
Например:
Project = IN_PROGRESSа отдельно:
Contract =
DRAFT
SENT
SIGNEDСистема может запретить запуск проекта до:
Contract = SIGNEDПолучаем зависимость между автоматами.
Это намного чище огромного enum из десятков комбинаций.
Архив — не всегда статус основного процесса
ARCHIVED часто используется как:
Скрыть из активной работы.
Возможно, это отдельное измерение:
isArchived = trueа бизнес-статус остаётся:
COMPLETEDПочему это полезно?
Потому что:
COMPLETEDотвечает:
Чем закончился процесс?
isArchived:
Нужно ли показывать объект среди активных?
Один статус не обязан отвечать сразу на несколько независимых вопросов.
Очень важно определить смысл каждого состояния письменно
Для каждого статуса полезно зафиксировать:
Когда начинается?
Кто может установить?
Какие действия доступны?
Когда заканчивается?
Какие следующие состояния разрешены?Например:
NEEDS_CLARIFICATION
Начинается:
Когда исполнитель не может продолжать анализ без дополнительных данных.
Инициатор:
Администратор.
Действия клиента:
Ответить, прикрепить файлы.
Действия администратора:
Дополнить вопрос, отменить заявку.
Выход:
ESTIMATION или CANCELLED.
Так статус перестаёт быть названием без точного значения.
Диаграмма полезнее таблицы из двадцати статусов
Даже простая схема быстро показывает проблемы:
NEW
|
+------> NEEDS_CLARIFICATION
| |
| v
+----------> ESTIMATION
|
v
OFFER_SENT
|
v
ACCEPTED
|
v
IN_PROGRESS
/ \
v v
SUSPENDED COMPLETEDИ отдельные переходы:
→ CANCELLEDПосле такой схемы часто обнаруживаются вопросы:
А как заявка возвращается из SUSPENDED?
Можно ли отменить OFFER_SENT?
Что происходит после отказа от КП?
Это и есть ценность моделирования до написания кода.
Необязательно использовать специальную библиотеку state machine
Для небольшого или среднего backend достаточно:
enum states;
transition table;
guards;
domain service;
tests.Отдельная state-machine library становится полезной при:
сложных вложенных состояниях;
параллельных состояниях;
очень большом количестве переходов;
workflow, которое удобно описывать декларативно.Технология вторична.
Главное — сама модель.
Тесты state machine особенно ценны
Можно буквально пройти все пары статусов.
Например:
NEW → ESTIMATION
ALLOW
NEW → COMPLETED
DENY
COMPLETED → NEW
DENYИ автоматически проверить весь transition graph.
После добавления нового состояния regression suite сразу покажет, какие отношения не определены.
Но ещё важнее тесты guards
Например:
ACCEPTED → IN_PROGRESSпри:
payment = confirmedдолжен пройти.
При:
payment = missingдолжен получить отказ.
Если это правило существует только в голове разработчика, рано или поздно появится обходной endpoint.
Нужно тестировать и права ролей
Например:
CLIENT:
OFFER_SENT → ACCEPTED
ALLOWCLIENT:
ESTIMATION → OFFER_SENT
DENYADMIN:
ESTIMATION → OFFER_SENT
ALLOWТак тест фиксирует не только техническую, но и бизнес-семантику процесса.
Хороший frontend не показывает невозможные действия
Если заявка:
COMPLETEDнет смысла показывать:
[Начать работу]затем при клике отвечать 409.
Интерфейс должен использовать capabilities и заранее скрывать или блокировать недоступные операции.
Но backend всё равно обязан их проверять.
Frontend отвечает за удобство.
Backend — за истинность процесса.
Иногда disabled лучше hidden
Если пользователь ожидает действие, но временно не может выполнить его, полезно показать:
[Начать работу] — недоступнои объяснение:
Сначала необходимо подтверждение оплаты.
Так state machine становится частью понятного UX.
Если просто скрыть кнопку, пользователь может не понять, что нужно сделать.
История должна быть понятна клиенту, а не только разработчику
Вместо:
STATUS_TRANSITION:
ESTIMATION → OFFER_SENTклиенту полезнее:
27 сентября, 14:32
Для заявки подготовлено коммерческое предложение.
А администратору можно показывать техническую детализацию дополнительно.
Одна state machine способна иметь разные представления для разных ролей.
State machine помогает строить правильный dashboard
Например, административный обзор можно группировать:
Требуют реакции:
NEW = 7
Ожидают клиента:
NEEDS_CLARIFICATION = 4
Нужно подготовить оценку:
ESTIMATION = 5
Ожидают решения клиента:
OFFER_SENT = 3Это гораздо полезнее простого:
Всего заявок: 482Потому что state напрямую связывается с работой, которую нужно выполнить.
State machine помогает и автоматизации
Можно создать правила:
NEW больше 2 часов
→ уведомить менеджераNEEDS_CLARIFICATION больше 3 дней
→ напомнить клиентуOFFER_SENT больше 5 дней
→ напомнить клиентуSUSPENDED больше 30 дней
→ запросить решениеБез точных состояний подобная automation быстро превращается в набор трудно проверяемых условий.
Но автоматический переход нужно использовать осторожно
Например:
OFFER_SENT
через 30 дней
→ CANCELLEDможет быть слишком агрессивным.
Возможно, правильнее:
OFFER_SENT
↓
EXPIREDили оставить статус и добавить:
offerExpired = trueЗдесь снова важно определить бизнес-смысл, а не просто автоматизировать ради автоматизации.
Почему запрещённые состояния важнее разрешённых
Когда проектируют workflow, естественно спрашивать:
Что пользователь должен уметь сделать?
Но для надёжности не менее полезен вопрос:
Что система никогда не должна позволить?
Например:
Завершить заявку без результата.Начать работу без согласованных условий.Подтвердить уже отменённое предложение.Изменить завершённую заявку обычным редактированием.Отменить проект после окончательной передачи без отдельного процесса.Так появляются настоящие domain invariants.
«Никогда» должно существовать в коде, а не только в документации
Если бизнес говорит:
Нельзя начать проект до оплаты.
Но backend позволяет:
PATCH /project
{
"status": "IN_PROGRESS"
}правило существует только формально.
State machine должна сделать невозможный переход технически недоступным.
Как понять, что процесс пора моделировать state machine
Один статус может оставаться обычным enum, если:
переходы практически не ограничены;
нет побочных эффектов;
нет разных ролей;
нет важной истории.State machine становится особенно полезной, когда появляются:
несколько этапов;
разные участники;
условия перехода;
уведомления;
оплаты;
сроки;
архив;
отмена;
audit;
автоматизация.То есть почти в любой зрелой CRM, SaaS или B2B-системе.
Типичная ошибка: state machine появляется слишком поздно
Первая версия:
status VARCHARРаботает.
Вторая:
Добавим ещё три статуса.
Третья:
Для этого статуса кнопку скрыть.
Четвёртая:
Если оплачено, можно менять.
Пятая:
Кроме отменённых.
Через год условная логика разбросана по:
frontend;
backend;
worker;
email;
analytics.Миграция к нормальной модели становится значительно дороже.
Поэтому если уже на этапе ТЗ видно, что объект проходит сложный бизнес-процесс, жизненный цикл полезно спроектировать заранее.
Практичная архитектура
Для большинства систем достаточно нескольких слоёв:
Request
│
├── currentState
│
├── revision
│
└── business dataRequestStateMachine
│
├── transitions
├── guards
└── permissionsRequestHistory
│
├── from
├── to
├── actor
├── reason
└── timestampи сервис:
transitionRequest()который централизует смену состояния.
Пример бизнес-операции
Концептуально:
async function startRequest({
requestId,
actor
}) {
const request =
await repository.get(requestId);
authorize(
actor,
'REQUEST_START',
request
);
stateMachine.assertTransition(
request.status,
'IN_PROGRESS'
);
assertSpecificationApproved(request);
assertOfferApproved(request);
assertPaymentConfirmed(request);
await transaction(async tx => {
await tx.requests.transition({
id: request.id,
from: request.status,
to: 'IN_PROGRESS'
});
await tx.history.create({
requestId,
from: request.status,
to: 'IN_PROGRESS',
actorId: actor.id
});
await tx.outbox.create({
type: 'REQUEST_STARTED',
requestId
});
});
}Здесь сразу видно отличие от:
UPDATE status = 'IN_PROGRESS'Переход является настоящей domain operation.
Это немного больше кода — но намного меньше хаоса потом
State machine требует заранее подумать.
Нужно определить:
состояния;
переходы;
guards;
permissions;
side effects.На маленьком прототипе это кажется дополнительной работой.
На зрелом продукте она окупается каждый раз, когда появляется новый статус, новая роль или новая автоматизация.
Главное преимущество — бизнес-процесс становится видимым
Разработчик может посмотреть transition graph и понять систему.
Product owner может посмотреть ту же схему и увидеть бизнес.
QA понимает, какие сценарии тестировать.
Frontend понимает, какие действия показывать.
Backend знает, что запрещать.
Analytics знает, какие этапы измерять.
Одна модель начинает соединять сразу несколько частей продукта.
Вместо вывода
В сложном веб-продукте статус заявки — это не цветная плашка.
Он определяет:
что уже произошло;
что сейчас происходит;
кто должен действовать;
что разрешено дальше;
что категорически запрещено.Поэтому зрелая модель строится не вокруг:
status = stringа вокруг:
State
+
Transition
+
Guard
+
Permission
+
HistoryПользователь инициирует бизнес-действие.
Backend проверяет, допустимо ли оно из текущего состояния.
Проверяет права.
Проверяет необходимые условия.
Атомарно выполняет переход.
Сохраняет историю.
После этого запускает уведомления и другие побочные процессы.
И главное — не позволяет создать состояние, которого в реальном бизнесе существовать не должно.
В этом и заключается главная ценность state machine.
Она не делает обычную заявку «сложнее ради архитектуры».
Она переносит уже существующие правила бизнеса из человеческих договорённостей и разрозненных if в явную модель, которую можно понимать, проверять и тестировать.
Если объект действительно имеет жизненный цикл, лучше один раз спроектировать этот жизненный цикл явно, чем потом годами объяснять коду, почему «Завершено → Новая → Оценка» всё-таки не должно происходить.