Представьте объект «заказ» с тремя булевыми полями:
public class Order {
private boolean isPaid;
private boolean isShipped;
private boolean isCancelled;
}
type Order struct {
isPaid bool
isShipped bool
isCancelled bool
}
class Order {
private isPaid = false;
private isShipped = false;
private isCancelled = false;
}
@dataclass
class Order:
is_paid: bool = False
is_shipped: bool = False
is_cancelled: bool = False
Поначалу удобно: оплатили — ставим isPaid = true, отправили — isShipped = true. Но флаги множатся. Три булевых поля дают восемь комбинаций, и часть из них — бессмыслица. Что значит isPaid = false, isShipped = true — отправили неоплаченный заказ? А isCancelled = true, isShipped = true — отменённый, но уже уехавший? Такие состояния не должны существовать, но код их разрешает: ничто не мешает выставить любую комбинацию.
Дальше хуже. Проверки расползаются по всему коду:
if (order.isPaid() && !order.isShipped() && !order.isCancelled()) {
// можно отправлять
}
if (!order.isCancelled() && (order.isPaid() || order.isShipped())) {
// нельзя отменять? или можно? уже не помню
}
if order.isPaid && !order.isShipped && !order.isCancelled {
// можно отправлять
}
if !order.isCancelled && (order.isPaid || order.isShipped) {
// нельзя отменять? или можно? уже не помню
}
if (order.isPaid && !order.isShipped && !order.isCancelled) {
// можно отправлять
}
if (!order.isCancelled && (order.isPaid || order.isShipped)) {
// нельзя отменять? или можно? уже не помню
}
if order.is_paid and not order.is_shipped and not order.is_cancelled:
... # можно отправлять
if not order.is_cancelled and (order.is_paid or order.is_shipped):
... # нельзя отменять? или можно? уже не помню
С каждым новым правилом эти if растут, дублируются и начинают противоречить друг другу. Проблема в том, что настоящего понятия «состояние заказа» в коде нет — оно размазано по флагам, и целостность держится только на дисциплине программиста. Конечные автоматы решают ровно это: делают состояние явным, а недопустимые переходы — невозможными.
Восемь комбинаций трёх флагов: три нижние ветви не описывают ни один реальный заказ, но ничто в коде не мешает их выставить.
Что такое конечный автомат
Конечный автомат (или стейт-машина, от англ. state machine) — это модель, в которой объект в каждый момент находится ровно в одном из заранее перечисленных состояний и переходит между ними по чётким правилам. Звучит абстрактно, но вы пользуетесь автоматами каждый день.
Возьмём турникет в метро. У него всего два состояния: «закрыт» и «открыт», и два события: «приложили карту» и «прошли». В закрытом турникет ждёт карту; приложили — перешёл в «открыт»; прошли — вернулся в «закрыт». А вот на событие «толкнули» он не реагирует вообще: в закрытом состоянии такого перехода нет. Это и есть суть автомата — каждое состояние принимает свой набор событий, остальные ничего не меняют.
Или светофор: зелёный → жёлтый → красный → зелёный. Порядок жёсткий, светофор не прыгает с зелёного сразу на красный.
Разберём словарь автомата на этих примерах:
- Состояние (state) — положение, в котором объект сейчас находится. У турникета — «закрыт» / «открыт». У заказа — «новый», «оплачен», «отправлен».
- Событие (event) — то, что происходит снаружи и может запустить переход: «приложили карту», «оплатили заказ», «нажали отмену».
- Переход (transition) — смена одного состояния на другое в ответ на событие. «Закрыт» + событие «оплата» → «открыт».
- Начальное состояние — то, с которого объект стартует. У заказа — «новый».
- Конечные состояния — те, из которых выхода уже нет. Заказ «доставлен» или «отменён» — дальше двигаться некуда.
- Guard (условие перехода) — дополнительная проверка, без которой переход не случится, даже если событие пришло. Например, оплатить заказ можно, только если сумма больше нуля.
- Действие при переходе (action) — что нужно сделать в момент перехода: списать деньги, отправить письмо, записать время. Действие привязано к переходу, а не разбросано по коду.
Про действие стоит сказать отдельно, потому что в примерах с турникетом его не видно, а в сервисе оно и есть самое дорогое. Переход «заказ оплачен» — это не только смена значения поля: нужно зарезервировать товар, отправить письмо покупателю, записать в историю, опубликовать событие для соседних сервисов. Всё это — действия, привязанные к переходу, а не к состоянию: они выполняются один раз в момент смены, а не каждый раз, когда кто-то смотрит на заказ.
Почему это важно уже здесь: как только у перехода появляются действия, простое присваивание поля перестаёт работать. Переход становится операцией, у которой есть порядок («сначала проверили, потом сменили состояние, потом действия»), возможность частичного отказа («состояние сменилось, а письмо не ушло») и требование не выполниться дважды. Отсюда и вырастает вся техника из следующей статьи: проверка, переход и действия должны быть одной операцией, а то, что уходит наружу, — отложенным.
Главная идея: весь набор допустимых переходов известен заранее и собран в одном месте. Если события «отправить» нет для состояния «отменён», значит отправить отменённый заказ нельзя — и это правило записано явно, а не подразумевается.
Пример: жизненный цикл заказа
Вернёмся к заказу, но теперь опишем его как автомат. Состояния:
NEW— создан, ждёт оплаты (начальное);PAID— оплачен, готов к отправке;SHIPPED— отправлен покупателю;DELIVERED— доставлен (конечное);CANCELLED— отменён (конечное).
Разрешённые переходы:
NEW → PAID— покупатель оплатил;PAID → SHIPPED— склад отправил;SHIPPED → DELIVERED— курьер довёз;NEW → CANCELLED— отменили неоплаченный заказ;PAID → CANCELLED— отменили оплаченный (с возвратом денег).
Наглядно это выглядит так:
Жизненный цикл заказа как конечный автомат: стрелки — разрешённые переходы. Отправить можно только оплаченный заказ (NEW → PAID → SHIPPED), а отменить — лишь до отправки. Отправленный заказ отменить нельзя: такого перехода в автомате просто нет.
А теперь — что запрещено, и почему это ценно:
SHIPPED → CANCELLED— отправленный заказ отменить нельзя, товар уже в пути. Автомат просто не знает такого перехода.NEW → SHIPPED— нельзя отправить неоплаченный заказ, минуяPAID.DELIVERED → что угодно— доставленный заказ — конечное состояние, из него нет выхода.CANCELLED → PAID— отменённый заказ оплатить нельзя, отмена конечна.
Ценность в том, что запрет — не забытая проверка в каком-то одном if, а свойство самой модели. Если код попытается перевести SHIPPED в CANCELLED, автомат ответит ошибкой: такого перехода не существует. Ошибка вылезает сразу и в одном месте, а не превращается в тихо испорченные данные, которые всплывут через неделю в отчёте.
Где автомат живёт в коде
Модель понятна, и сразу возникает вопрос: а физически это что — класс, таблица, сервис? Ответ короткий: автомат живёт там, где живёт объект, которому он принадлежит, и почти всегда это сам объект, а не отдельная сущность.
Состояние — поле объекта, а переходы — его методы. Не orderService.setStatus(order, PAID), а order.pay(...): метод сам проверяет, допустим ли переход из текущего состояния, и меняет поле. Тогда невозможно перевести заказ мимо правил, потому что снаружи поле недоступно. Это то же правило, что в доменной модели: состояние меняется только через поведение.
Правила переходов — рядом с состоянием, а не в сервисе. Либо в самом объекте (методы с проверками), либо в перечислении состояний (каждое состояние знает, куда из него можно), либо в таблице переходов — выбор между этими тремя способами и разбирает следующая статья. Общее у всех трёх: правила в одном месте, а не размазаны по обработчикам запросов.
Отдельный сервис-автомат нужен редко. Он оправдан, когда процесс не принадлежит одному объекту: заявка идёт через три системы, состояние складывается из нескольких источников, шаги выполняются днями. Это уже не автомат внутри сущности, а координатор процесса — и про него статья про движки процессов.
И то, что живёт в базе, — не только текущее состояние. Здесь стоит сказать прямо, потому что это ломает первое впечатление: переход в базе — это не присваивание поля. Между чтением объекта и записью нового состояния проходит время, и за это время кто-то другой мог перевести тот же заказ; два обработчика одного события могут выполнить переход дважды. Значит, у перехода должна быть защита от одновременной записи, а у действий — защита от повтора. Механика — в следующей статье; здесь важно не уйти с мыслью, что автомат — это status = PAID.
Флаги против стейт-машины
Сравним два подхода на одной задаче — «можно ли отправить заказ».
С флагами условие приходится собирать вручную и повторять везде, где оно нужно:
if (order.isPaid() && !order.isShipped() && !order.isCancelled()) {
order.setShipped(true);
}
if order.isPaid && !order.isShipped && !order.isCancelled {
order.isShipped = true
}
if (order.isPaid && !order.isShipped && !order.isCancelled) {
order.isShipped = true;
}
if order.is_paid and not order.is_shipped and not order.is_cancelled:
order.is_shipped = True
Что тут плохо:
- Невозможные состояния разрешены. Три булевых поля дают восемь комбинаций, и три из них — чистая бессмыслица: «отправлен, но не оплачен», «отправлен, не оплачен и отменён», «оплачен, отправлен и отменён». Ничто не мешает выставить
isPaid = false, isShipped = true— код это проглотит. - Правило дублируется. Каждый раз, когда нужно понять «в каком заказ состоянии», условие пишут заново — и легко ошибиться в одной из копий.
- Нет единого списка правил. Чтобы узнать, какие переходы вообще возможны, придётся вычитать весь код и держать логику в голове.
С автоматом заказ хранит одно поле-состояние, а правила собраны в одном месте:
public enum OrderStatus { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
public class Order {
private OrderStatus status = OrderStatus.NEW;
public void ship() {
if (status != OrderStatus.PAID) {
throw new OrderCannotBeShippedException(status);
}
this.status = OrderStatus.SHIPPED;
}
}
type OrderStatus string
const (
StatusNew OrderStatus = "NEW"
StatusPaid OrderStatus = "PAID"
StatusShipped OrderStatus = "SHIPPED"
StatusDelivered OrderStatus = "DELIVERED"
StatusCancelled OrderStatus = "CANCELLED"
)
type Order struct {
status OrderStatus
}
func (o *Order) Ship() error {
if o.status != StatusPaid {
return OrderCannotBeShippedError{Status: o.status}
}
o.status = StatusShipped
return nil
}
enum OrderStatus { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
class Order {
private status = OrderStatus.NEW;
ship(): void {
if (this.status !== OrderStatus.PAID) {
throw new OrderCannotBeShippedException(this.status);
}
this.status = OrderStatus.SHIPPED;
}
}
class OrderStatus(Enum):
NEW = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
class Order:
def __init__(self) -> None:
self.status = OrderStatus.NEW
def ship(self) -> None:
if self.status is not OrderStatus.PAID:
raise OrderCannotBeShipped(self.status)
self.status = OrderStatus.SHIPPED
Ошибка здесь своя, доменная, а не IllegalStateException, ValueError или голый Error из стандартной библиотеки — и это не придирка к именам. IllegalStateException говорит «что-то пошло не так внутри», и на границе сервиса из неё нечего достать: в ответ клиенту уйдёт безликая пятисотка. OrderCannotBeShippedException — это факт предметной области, у него есть имя, есть текущий статус внутри, и обработчик ошибок может превратить его в понятный код ответа.
Что даёт этот подход:
- Состояние явное. В любой момент заказ — ровно в одном из перечисленных
enum-состояний. Невозможных комбинаций больше нет: «оплачен и отменён одновременно» просто не выразить. - Запрещённый переход = ошибка. Попытка отправить неоплаченный заказ бросает исключение сразу, а не портит данные молча.
- Единый список правил. Все допустимые переходы описаны в одном месте. Чтобы понять поведение заказа, достаточно посмотреть на таблицу состояний, а не вычитывать разрозненные
if.
Для сложных случаев проверку перехода выносят в отдельную структуру или готовый инструмент (например, Spring Statemachine у Java-стека, stateless или looplab/fsm у Go, либо движок процессов вроде Camunda с нотацией BPMN), но идея та же: одно поле-состояние и один свод правил вместо россыпи флагов.
Когда хватит if, а когда нужен автомат
Автомат — не всегда правильный ответ. Иногда обычные if проще и честнее, и раздувать код стейт-машиной незачем. Ориентиры простые.
Хватит обычных проверок, если:
- состояний два-три и они меняются линейно, без ветвлений («черновик» → «опубликовано»);
- запрещённых переходов по сути нет — двигаться можно только вперёд;
- логика не растёт и переходы не важны сами по себе (не нужно знать, кто и когда перевёл объект).
Пора вводить автомат, если:
- состояний много и между ними сложная сеть переходов, часть которых запрещена;
- важен запрет конкретных переходов, и цена ошибки высока (деньги, отгрузка, юридические статусы);
- нужен аудит переходов — история «кто, когда и из какого состояния перевёл объект»;
- логика постоянно растёт: новые состояния и правила добавляют регулярно, а
ifуже начали противоречить друг другу.
Практический признак: если, глядя на код, вы не можете быстро ответить «какие состояния вообще бывают и какие переходы разрешены» — значит модели состояния не хватает, и её пора сделать явной. Пока же всё умещается в пару линейных шагов — не усложняйте.
Глубже: журнал переходов: как выглядит аудитрасширенное
Аудит назван выше признаком «пора вводить автомат», и стоит показать, что это значит физически. Текущее состояние — это одно поле; история переходов — отдельная таблица, и без неё на вопрос «почему заказ отменён и кто его отменил» ответить нечем.
CREATE TABLE order_status_history (
id bigserial PRIMARY KEY,
order_id uuid NOT NULL REFERENCES orders (id),
from_status text, -- NULL для первого перехода
to_status text NOT NULL,
event text NOT NULL, -- что вызвало переход: PAYMENT_RECEIVED
actor text NOT NULL, -- кто: user:42, system:payment-relay, support:ivanov
reason text, -- почему, если это решение человека
occurred_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT status_history_unique UNIQUE (order_id, to_status, occurred_at)
);
CREATE INDEX idx_status_history_order ON order_status_history (order_id, occurred_at);
Что в этой таблице важно по полям:
from_statusиto_statusвместе. Одного «стало» мало: по паре видно, был ли переход законным, и по ней же потом проверяют, не появилось ли в проде переходов, которых нет в правилах.event— почему переход случился. Одно и то же состояние бывает достигнуто разными путями («отменён покупателем» и «отменён по таймауту оплаты»), и без события они неразличимы.actor— кто. Три вида: пользователь, система (с именем процесса), сотрудник поддержки. Последний — самый частый ответ на вопрос «откуда взялось это состояние».reason— свободный текст для решений человека. Поддержка отменила заказ — здесь написано, почему.occurred_at— когда. По разнице между соседними записями считают, сколько заказ провёл в каждом состоянии, — и это самая полезная аналитика, которую даёт журнал бесплатно.
Три вещи, которые стоит знать про такой журнал сразу:
Запись идёт в той же транзакции, что и переход. Иначе журнал разойдётся с состоянием: переход есть, записи нет. Это естественное требование — и одновременно причина, по которой журнал нельзя писать «из соседнего сервиса».
Журнал только дописывается. Строки не меняются и не удаляются; исправление ошибки — новая строка, а не правка старой. Иначе это не аудит.
Из него восстанавливается текущее состояние. Полезная проверка: последняя запись журнала должна совпадать с полем status в заказе. Расхождение означает, что кто-то сменил состояние мимо перехода — обычно правкой в базе руками, и это находка, а не мелочь.
Что этот журнал даёт, кроме ответа поддержке: сколько заказов застряло в состоянии дольше нормы (первый признак сломанного процесса), сколько времени в среднем занимает каждый шаг, какие переходы вообще не случаются (значит, правило мёртвое), и — при разборе аварии — точная хронология без догадок.
Глубже: когда состояний становится многорасширенное
Модель «один статус на объект» выглядит естественной, пока состояний пять. Когда их двадцать — это признак того, что в одно поле сложили несколько процессов, и дальше с ним работать нельзя. Три способа, которые применяют, по возрастанию цены.
Разделить на несколько автоматов. Самый частый и правильный ответ. У заказа обычно не один процесс, а три: оплата (не оплачен → ожидает подтверждения → оплачен → возвращён), сборка и отгрузка (собирается → передан перевозчику → доставлен), и сам заказ (оформлен → выполнен → отменён). Это три независимых состояния, каждое со своими правилами, а «статус заказа», который видит пользователь, — производная от них. Признак, что пора: в перечислении есть значения, которые не сравнимы между собой («ожидает оплаты» и «частично отгружен» могут быть одновременно).
Ввести подсостояния. Когда состояние крупное и внутри него есть своя жизнь: «в обработке» разворачивается в «проверяется», «комплектуется», «упаковывается». Снаружи (в интерфейсе, в отчётах) видно только «в обработке», внутри — подробности. Дешевле, чем отдельный автомат, и подходит, когда внутренние шаги строго последовательны.
Разделить состояние и признаки. Не всё, что похоже на состояние, им является. «Оплачен» — состояние; «есть спор», «отмечен для проверки», «подарочная упаковка» — признаки, живущие параллельно. Попытка загнать их в один автомат даёт перечисление вида PAID_WITH_DISPUTE_FLAGGED, и это верный признак ошибки: состояния перемножаются, признаки складываются.
И самый важный перегиб, о котором стоит предупредить отдельно: иногда нужен не автомат, а журнал фактов. Если у объекта не «текущее состояние», а история событий, которые могут накладываться (частичные отгрузки, несколько платежей, возвраты по позициям), одно поле status не выразит реальность никогда — оно будет врать в половине случаев. Признаки: приходится придумывать состояния вида «частично отгружен, частично возвращён»; на вопрос «оплачен ли заказ» правильный ответ «на 70 %»; поддержка просит показать, что происходило, а не что сейчас. Тогда правда — в наборе фактов (платежи, отгрузки, возвраты как отдельные записи), а статус, если он нужен интерфейсу, вычисляется из них. Разбор такой модели — в статье про журнал событий как источник правды.
Коротко
- Конечный автомат (стейт-машина) — модель, где объект всегда находится ровно в одном из перечисленных состояний и меняет их по чётким переходам в ответ на события; аналогия — турникет или светофор.
- Словарь автомата: состояние, событие, переход, начальное и конечные состояния, guard (условие перехода) и действие при переходе.
- Жизненный цикл заказа
NEW → PAID → SHIPPED → DELIVERED(иCANCELLED) явно задаёт, какие переходы разрешены, а запрещённые (SHIPPED → CANCELLED,NEW → SHIPPED) делает невозможными. - Булевы флаги разрешают невозможные комбинации, дублируют правила и прячут их по коду; автомат даёт явное состояние, ошибку на запрещённый переход и единый список правил.
- Пара линейных состояний — хватит
if; много состояний с запрещёнными переходами, важным аудитом и растущей логикой — нужен автомат. - Автомат живёт в самом объекте: состояние — поле, переходы — методы с проверками, правила в одном месте; отдельный сервис-автомат нужен только для процесса, который не принадлежит одному объекту.
- Действия привязаны к переходу, а не к состоянию, и именно из-за них переход в базе перестаёт быть присваиванием поля: нужна защита от одновременной записи и от повтора действий.
- Аудит — отдельная только дописываемая таблица с парой «из какого, в какое», событием, актором, причиной и временем; пишется в той же транзакции, и её последняя запись обязана совпадать с текущим состоянием.
- Двадцать состояний означают несколько процессов в одном поле: их разделяют на независимые автоматы с производным статусом, вводят подсостояния или отделяют признаки от состояний.
- Когда факты накладываются (несколько платежей, частичные отгрузки, возвраты), нужен не автомат, а журнал фактов, а статус вычисляется из них.
Что пощупать
Автомат статусов платежа в практикуме remodov/marketplace-system умещается в один метод перечисления: перечислены разрешённые переходы, всё остальное запрещено по умолчанию, конечные статусы никуда не ведут, а переход в себя же не переход. Проверяет его тест без фреймворка и базы, а заказ рядом хранит десять статусов с переходами в методах агрегата. В Go-практикуме remodov/marketplace-system-go то же делает именованный тип со switch в сервисе платежей: Status.CanMoveTo перечисляет разрешённое, остальное запрещено по умолчанию.
Код: Payment.java, статусы заказа.
Сделаем сами
Ветка step-11-saga-and-state-machine — разрешённые переходы платежа не описаны, PaymentTransitionsTest и PaymentApiTest красные, условие по ссылке в TASK.md.
Что почитать дальше
- Реализация автомата — перечисление и
switch, таблица переходов, состояние как класс, готовая библиотека; хранение, гонки и журнал переходов. - Реализация автомата на Go — именованный тип и
switch, таблица переходов наmap, состояние как тип,statelessиlooplab/fsm; гонки черезversionиFOR UPDATE. - Движки процессов и оркестрация — когда процесс не принадлежит одному объекту и нужен координатор.
- Журнал событий как источник правды — что делать, когда одного поля состояния не хватает.