State Machine простыми словами: состояния и переходы объекта
Во многих информационных системах объект существует не просто как набор данных. В течение своей жизни он проходит через несколько состояний.
Например, заявка может быть создана, отправлена, передана на проверку, согласована или отклонена.
Простейший жизненный цикл можно представить так:
Draft -> Submitted -> UnderReview -> Approved
Но одного списка статусов недостаточно.
Нужно понимать:
- из какого состояния разрешён переход;
- какое событие запускает переход;
- какие условия должны выполняться;
- что произойдёт после изменения состояния;
- какие переходы запрещены;
- как хранить историю изменений;
- кто имеет право выполнить переход.
Для формального описания такого поведения используется State Machine, или конечный автомат.
State Machine позволяет описать жизненный цикл объекта через набор состояний и правила перехода между ними.
Что такое State Machine
State Machine описывает систему через несколько основных понятий:
State - текущее состояние объекта
Transition - переход между состояниями
Event - событие, которое вызывает переход
Guard - условие, разрешающее переход
Action - действие, связанное с переходом
Например:
Draft -> Submit -> Submitted -> StartReview -> UnderReview -> Approve -> Approved
Здесь:
Draft - State
Submit - Event
Draft -> Submitted - Transition
Можно читать это так:
Если заявка находится в состоянии Draft и происходит событие Submit, она переходит в состояние Submitted.
После этого событие StartReview переводит её в UnderReview, а Approve переводит в Approved.
State Machine описывает не просто список возможных статусов, а правила изменения состояния объекта.
Это принципиальное различие.
State и status
В обычном коде состояние часто хранится в поле:
status
Например:
status = "draft"
status = "submitted"
status = "under_review"
status = "approved"
Поэтому в прикладной разработке слова state и status часто используются почти как синонимы.
Но концептуально State Machine шире одного поля status.
Состояние определяет:
- что сейчас происходит с объектом;
- какие операции над ним разрешены;
- какие переходы доступны;
- какие события могут быть обработаны.
Например, если заявка уже находится в Approved, операция:
Approve application
скорее всего, больше недоступна.
А если заявка находится в Draft, пользователь может:
редактировать -> прикреплять документы -> отправить
После перехода в Submitted часть этих действий может стать недоступной.
Состояние начинает влиять на поведение системы.
Почему списка статусов недостаточно
Простейшая реализация может выглядеть так:
status = any string
Или немного лучше:
status IN (
"draft",
"submitted",
"under_review",
"approved",
"rejected"
)
Второй вариант уже ограничивает набор значений, но всё ещё не отвечает на вопрос:
Какие переходы между этими значениями разрешены?
Например:
Draft -> Approved
технически может оказаться возможным, хотя по бизнес-процессу заявка сначала должна быть отправлена и проверена.
Или:
Rejected -> Approved
может быть запрещено полностью.
В таком случае возврат отклонённой заявки должен происходить через отдельный процесс:
Rejected -> Reopened -> Submitted -> UnderReview -> Approved
Хорошая модель определяет не только:
разрешённые состояния
но и:
разрешённые переходы между ними
Поэтому State Machine можно представить как граф:
State -> разрешённый Transition -> State
Не каждая вершина этого графа соединена со всеми остальными.
Transition
Transition - переход объекта из одного состояния в другое.
Например:
Draft -> Submitted
Submitted -> UnderReview
UnderReview -> Approved
UnderReview -> Rejected
Можно сразу заметить, что:
Draft -> Rejected
отсутствует.
Это означает, что такой переход запрещён.
В коде разрешённые переходы можно представить явно:
TRANSITIONS = {
"draft": {"submitted"},
"submitted": {"under_review"},
"under_review": {"approved", "rejected"},
"approved": set(),
"rejected": set(),
}
Перед изменением состояния система проверяет:
current_state -> target_state
и убеждается, что такой переход существует.
Это значительно безопаснее, чем разрешить любой части приложения напрямую записывать новое значение в status.
Event
Переход обычно происходит не сам по себе.
Его вызывает Event - событие.
Например:
Submit -> Draft -> Submitted
StartReview -> Submitted -> UnderReview
Approve -> UnderReview -> Approved
Reject -> UnderReview -> Rejected
Здесь состояния описывают положение объекта, а события описывают то, что произошло.
Хорошее разделение:
State - Submitted
Event - StartReview
Не стоит называть состояние:
Submitting
Approve
Create
если на самом деле это действия.
Состояние обычно отвечает на вопрос:
В каком положении сейчас находится объект?
Событие отвечает:
Что произошло?
Например:
State: UnderReview
Event: Approve
Next State: Approved
Guard Conditions
Одного правильного текущего состояния иногда недостаточно.
Представим:
UnderReview -> Approve -> Approved
Но согласовать заявку можно только после проверки документов.
Тогда появляется Guard.
Guard - условие, которое должно выполняться, чтобы transition был разрешён.
Например:
UnderReview -> Approve [documents_verified == true] -> Approved
Если:
documents_verified == false
событие Approve не должно переводить объект в Approved.
Guard может проверять:
- наличие документов;
- права пользователя;
- сумму платежа;
- заполненность обязательных полей;
- результаты проверки;
- наличие связанной сущности;
- выполнение предыдущего шага.
Например:
Draft -> Submit [required_fields_filled == true] -> Submitted
или:
UnderReview -> Approve [actor.role == manager] -> Approved
При этом важно не превращать Guard в огромный скрытый бизнес-процесс.
Guard должен отвечать на конкретный вопрос:
Разрешён ли сейчас этот переход?
Action
Переход может сопровождаться действием.
Например:
UnderReview -> Approve -> Approved
может дополнительно требовать:
записать дату согласования
или:
сохранить пользователя, который выполнил переход
Это можно рассматривать как Action, связанную с transition.
Например:
Approve
Guard: documents_verified == true
Transition: UnderReview -> Approved
Action: approved_at = now()
Но здесь появляется важная граница между изменением состояния и внешними побочными эффектами.
Side Effects
После перехода система часто должна выполнить дополнительные действия.
Например:
UnderReview -> Approved
может привести к:
- записи в audit log;
- отправке email;
- созданию Celery task;
- отправке webhook;
- публикации события в RabbitMQ;
- обновлению другой сущности;
- созданию документа.
Это side effects - побочные эффекты перехода.
Их полезно отделять от самого state transition.
Основная логика:
проверить transition -> изменить состояние -> сохранить изменение -> запустить необходимые side effects
Почему это важно?
Представим:
отправить email -> затем сохранить status = approved
Email успешно ушёл, но транзакция базы данных завершилась ошибкой.
Пользователь получил сообщение:
Ваша заявка согласована.
А в базе:
status = under_review
Система оказалась в противоречивом состоянии.
Поэтому изменение критичных данных обычно должно надёжно фиксироваться до выполнения внешних действий.
Для сложных асинхронных систем могут использоваться механизмы вроде Transactional Outbox:
изменение состояния + запись события в одной транзакции -> последующая доставка события
Но сама State Machine не определяет конкретный механизм доставки. Она описывает правила жизненного цикла объекта.
Начальное состояние
У State Machine обычно есть точка входа.
Например:
Initial -> Draft
После создания заявка автоматически получает:
status = Draft
Дальше переходы возможны только из этого состояния.
Например:
Draft -> Submitted -> UnderReview -> Approved
Начальное состояние важно определить явно.
Иначе возникают вопросы:
- может ли объект создаваться сразу в Submitted;
- может ли оператор создать Approved запись;
- всегда ли Draft существует;
- откуда вообще начинается жизненный цикл.
Конечные состояния
Некоторые состояния завершают жизненный цикл объекта.
Например:
Approved
Rejected
Cancelled
Но не всегда конечное бизнес-состояние означает невозможность дальнейших переходов.
Например:
Rejected -> Reopened
может быть допустимым сценарием.
Поэтому терминальное состояние определяется не названием, а правилами модели.
Если после:
Deleted
никакие переходы невозможны, это действительно terminal state.
Если из Rejected разрешено повторное открытие, то Rejected не является окончательной точкой State Machine.
Один статус не должен заменять несколько независимых признаков
Одна из распространённых ошибок выглядит так:
status =
approved_and_paid_and_email_sent
Здесь в одном статусе смешано сразу несколько независимых характеристик:
application_state
payment_state
notification_state
Получается комбинаторный взрыв.
Например:
approved_not_paid
approved_paid
approved_paid_email_sent
approved_not_paid_email_sent
rejected_not_paid
...
Количество состояний начинает быстро расти.
Если признаки действительно независимы, лучше моделировать их отдельно.
Например:
Application.status = approved
Payment.status = paid
Notification.status = delivered
У каждой сущности может быть собственная State Machine.
Это значительно проще, чем пытаться описать состояние всей системы одной строкой.
State Machine и BPMN
State Machine и BPMN действительно могут выглядеть похожими, потому что обе модели содержат переходы.
Но они отвечают на разные вопросы.
BPMN показывает процесс.
Например:
Получить заявку -> Проверить -> Согласовать -> Уведомить пользователя
State Machine показывает жизненный цикл конкретного объекта.
Например:
Draft -> Submitted -> UnderReview -> Approved
Главное различие можно представить так:
BPMN - что происходит в процессе
State Machine - в каком состоянии находится объект и куда он может перейти
Один BPMN-процесс может одновременно изменять несколько сущностей.
Например:
Application: UnderReview -> Approved
User: Applicant -> Student
Document: Generated -> Sent
Для каждой из них может существовать собственная State Machine.
Как BPMN и State Machine работают вместе
Представим BPMN-фрагмент:
Проверить заявку -> Данные корректны? -> Согласовать заявку
State Machine той же Application может показывать:
Submitted -> UnderReview -> Approved
BPMN объясняет, почему и в каком процессе происходит изменение.
State Machine объясняет, допустимо ли это изменение для конкретной сущности.
Эти модели не заменяют друг друга.
Они описывают одну систему с разных сторон.
State Machine и база данных
Самый простой вариант хранения:
Application
id
status
...
Например:
CREATE TABLE application (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL
);
Но база хранит только текущее состояние.
Если нужно знать историю, можно добавить отдельную таблицу.
Application
id
current_status
и:
ApplicationStatusHistory
id
application_id
old_status
new_status
changed_at
changed_by
Связь:
Application 1:N ApplicationStatusHistory
Теперь можно узнать:
- текущее состояние;
- когда оно изменилось;
- из какого состояния был выполнен переход;
- кто выполнил операцию.
Например:
Draft -> Submitted -> UnderReview -> Approved
может оставить историю:
Draft -> Submitted | 10:15 | user_42
Submitted -> UnderReview | 10:20 | operator_7
UnderReview -> Approved | 11:03 | manager_2
Нужно ли хранить transitions отдельной таблицей
Это зависит от системы.
Разрешённые transitions могут быть определены непосредственно в коде:
TRANSITIONS = {
"draft": {"submitted"},
"submitted": {"under_review"},
"under_review": {"approved", "rejected"},
}
Для относительно стабильной бизнес-логики это часто наиболее простой вариант.
Но в системах, где workflow настраивается пользователем, переходы могут храниться как данные:
State
Transition
Workflow
TransitionRule
Например, администратор может сам создавать:
New -> Processing -> Verification -> Completed
Тогда State Machine становится частью конфигурации системы.
Не нужно автоматически переносить transitions в базу только потому, что они существуют концептуально.
Нужно понять, является ли workflow частью программного кода или изменяемой бизнес-конфигурацией.
State Machine и API
State Machine сильно влияет на проектирование API.
Рассмотрим простой endpoint:
PATCH /applications/42
с телом:
{
"status": "approved"
}
На первый взгляд удобно.
Но теперь клиент API фактически получает возможность самостоятельно выбирать следующее состояние.
Он может попытаться отправить:
{
"status": "approved"
}
для заявки в состоянии:
Draft
или даже:
{
"status": "something"
}
если сервер плохо проверяет значения.
Более явный вариант:
POST /applications/42/approve
Теперь клиент говорит не:
Поставь status = approved.
а:
Выполни бизнес-операцию approve.
Backend уже проверяет:
текущее состояние -> права -> guard conditions -> transition
и только после этого меняет состояние.
Аналогично:
POST /applications/42/submit
POST /applications/42/start-review
POST /applications/42/approve
POST /applications/42/reject
Это command-oriented подход.
Он особенно удобен, если переход содержит сложную бизнес-логику.
Значит ли это, что PATCH использовать нельзя
Нет.
PATCH сам по себе не является проблемой.
Можно реализовать:
PATCH /applications/42
{
"status": "approved"
}
и внутри backend выполнить полноценную проверку transition.
Проблема возникает, когда API воспринимает status как обычное свободно изменяемое поле.
Например:
application.status = request.data["status"]
application.save()
без проверки жизненного цикла.
Важно не название HTTP endpoint, а наличие единого механизма переходов.
Однако отдельные команды вроде:
approve
reject
submit
cancel
часто лучше отражают бизнес-смысл операции.
Где должна находиться проверка transition
Проверять переход только во frontend недостаточно.
Frontend может скрыть кнопку:
Approve
если операция недоступна.
Но backend всё равно должен самостоятельно проверить переход.
Правило:
Frontend - помогает пользователю выбрать допустимое действие
Backend - гарантирует допустимость transition
Иначе внешний клиент или прямой HTTP-запрос сможет обойти интерфейс.
Права и State Machine
Разрешённый переход может зависеть не только от состояния, но и от пользователя.
Например:
Draft -> Submitted
может выполнять сам автор заявки.
А:
UnderReview -> Approved
только руководитель.
Получается:
Transition = current state + event + actor + guards
Например:
UnderReview
+
Approve
+
role == manager
+
documents_verified == true
->
Approved
Это уже полноценное бизнес-правило.
State Machine помогает собрать такие правила в одном месте, а не распределять случайные проверки по десяткам контроллеров и сервисов.
Параллельные процессы и State Machine
Не всё удобно выражать одной State Machine.
Представим заказ.
У него одновременно могут существовать:
Order status
Payment status
Delivery status
Плохо создавать состояния:
created_not_paid_not_shipped
created_paid_not_shipped
created_paid_shipped
cancelled_refunded_not_shipped
...
Гораздо проще иметь несколько связанных автоматов:
Order: New -> Confirmed -> Completed
Payment: Pending -> Paid -> Refunded
Delivery: Preparing -> Shipped -> Delivered
После этого бизнес-правила связывают их.
Например:
Order можно завершить только если Payment = Paid и Delivery = Delivered
State Machine должна описывать одну понятную ответственность.
State Machine и события системы
Переход можно использовать как источник доменного события.
Например:
UnderReview -> Approved
порождает:
ApplicationApproved
На это событие могут реагировать другие части системы:
ApplicationApproved -> отправить уведомление
ApplicationApproved -> создать участника
ApplicationApproved -> сформировать документы
ApplicationApproved -> обновить аналитику
Так State Machine хорошо сочетается с event-driven архитектурой.
При этом важно разделять:
Transition - изменение состояния
Domain Event - факт того, что изменение произошло
Side Effect - реакция другой части системы
Например:
Approve command -> UnderReview -> Approved -> ApplicationApproved -> send email
Это намного понятнее, чем одна большая функция approve_application(), внутри которой одновременно изменяется база, отправляются письма, вызываются API и создаются документы без явного разделения ответственности.
Идемпотентность переходов
В распределённых системах одно событие иногда может быть доставлено повторно.
Например, команда:
Approve application
пришла два раза.
Если объект уже находится в:
Approved
система должна понимать, что делать.
Варианты зависят от бизнес-логики:
повторный Approve -> вернуть текущее состояние
или:
повторный Approve -> ошибка invalid transition
Главное, чтобы поведение было определено явно.
Особенно важно это для:
- webhook;
- message queues;
- retry;
- асинхронных задач;
- сетевых запросов.
Как спроектировать State Machine с нуля
Начинать лучше не с рисования стрелок, а с жизненного цикла сущности.
Представим Application.
Сначала определяем основные состояния:
Draft
Submitted
UnderReview
Approved
Rejected
Затем события:
Submit
StartReview
Approve
Reject
После этого строим переходы:
Draft -> Submit -> Submitted
Submitted -> StartReview -> UnderReview
UnderReview -> Approve -> Approved
UnderReview -> Reject -> Rejected
Затем добавляем guards:
Draft -> Submit [required_fields_filled] -> Submitted
UnderReview -> Approve [documents_verified] -> Approved
После этого определяем права:
Submit -> applicant
StartReview -> operator
Approve -> manager
Reject -> manager
И только затем side effects:
Submitted -> уведомить оператора
Approved -> создать участника + отправить письмо
Rejected -> уведомить заявителя
В итоге проектирование идёт так:
Сущность -> состояния -> события -> переходы -> guards -> права -> side effects -> хранение истории -> API
Типичные ошибки
Первая ошибка - хранить status как произвольную строку.
status = any string
Система перестаёт контролировать множество состояний.
Вторая ошибка - определить допустимые статусы, но не определить transitions.
Draft
Submitted
Approved
Rejected
ещё не являются State Machine.
Нужно знать:
кто -> откуда -> куда -> при каком событии -> при каких условиях
Третья ошибка - использовать один status для нескольких независимых процессов.
Например:
approved_paid_email_sent
вместо отдельных моделей состояния.
Четвёртая ошибка - создавать слишком много состояний.
Не каждое техническое действие является новым State.
Например:
LoadingUserData
CallingDatabase
SerializingResponse
обычно не являются бизнес-состояниями Application.
Пятая ошибка - называть State действием.
Плохо:
Approve
Send
Check
Лучше:
Approved
Submitted
UnderReview
Шестая ошибка - менять status напрямую из разных частей приложения.
Например:
application.status = "approved"
в пяти разных сервисах.
Лучше иметь один механизм transition, который проверяет правила.
Седьмая ошибка - выполнять внешние side effects до фиксации критичного изменения данных.
Сначала система должна надёжно зафиксировать новое состояние, затем организовать выполнение зависимых операций.
Восьмая ошибка - не хранить историю там, где она важна.
Если бизнесу необходимо знать:
кто изменил статус
когда
откуда
куда
одного current_status недостаточно.
Как проверить State Machine
Перед завершением модели полезно проверить несколько вопросов:
- Определено ли начальное состояние?
- Понятно ли значение каждого State?
- Названы ли состояния как состояния, а события как действия?
- Для каждого ли перехода существует понятное событие?
- Есть ли запрещённые переходы?
- Определены ли Guard Conditions?
- Понятно ли, кто имеет право выполнить transition?
- Отделены ли side effects от изменения State?
- Нужна ли история состояний?
- Не объединены ли несколько независимых процессов одним status?
- Что произойдёт при повторном событии?
- Можно ли выполнить переход в обход основного API?
- Соответствует ли State Machine реальному бизнес-процессу?
Если ответы определены явно, поведение объекта становится намного предсказуемее.
Что важно запомнить
State Machine описывает жизненный цикл одного объекта.
Основные понятия:
State - текущее состояние
Event - произошедшее действие или событие
Transition - разрешённое изменение состояния
Guard - условие перехода
Action - действие, связанное с переходом
Главная идея:
не любой State -> любой State
а:
Current State + Event + Guards -> разрешённый Transition -> New State
Например:
UnderReview + Approve + documents_verified == true -> Approved
State Machine тесно связана с другими моделями системы:
BPMN - показывает процесс
State Machine - жизненный цикл сущности
ERD - хранение текущего состояния и истории
API - команды, вызывающие transitions
Event-driven architecture - реакции на произошедшие переходы
Поэтому State Machine особенно полезна там, где у объекта есть выраженный жизненный цикл: заявки, заказы, платежи, документы, сделки, задачи и другие сущности со статусами.
Главная цель State Machine не в том, чтобы нарисовать много кружков и стрелок.
Хорошая модель должна позволить посмотреть на объект и однозначно понять:
где он находится сейчас -> какие действия доступны -> при каких условиях -> куда он может перейти -> что произойдёт после перехода
После этого статус перестаёт быть случайной строкой в базе и становится формальной частью бизнес-логики системы.