← назад к разделу

Представьте объект «заказ» с тремя булевыми полями:

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 DELIVERED 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.

Что почитать дальше