Автомат состояний спроектирован: нарисована диаграмма, известны состояния заказа, события и разрешённые переходы. Но диаграмма на бумаге ничего не гарантирует сама по себе — всё решает то, как вы перенесёте её в код. Легко написать так, что «случайно» окажется возможным перейти из отменённого заказа в оплаченный, или что два потока одновременно сделают взаимоисключающие переходы. Задача — воплотить автомат так, чтобы недопустимые переходы были невозможны, а не просто «не предусмотрены». Способов несколько — от простого enum со switch до готовой библиотеки; разберём их по нарастанию сложности и подскажем, что когда брать.
enum состояний и switch
Самый прямой способ: состояния — это enum, а логика перехода — один метод с switch, который по текущему состоянию и событию решает, куда двигаться дальше.
public enum OrderState {
NEW, PAID, SHIPPED, DELIVERED, CANCELLED
}
public enum OrderEvent {
PAY, SHIP, DELIVER, CANCEL
}
public OrderState next(OrderState current, OrderEvent event) {
return switch (current) {
case NEW -> switch (event) {
case PAY -> OrderState.PAID;
case CANCEL -> OrderState.CANCELLED;
default -> throw illegal(current, event);
};
case PAID -> switch (event) {
case SHIP -> OrderState.SHIPPED;
case CANCEL -> OrderState.CANCELLED;
default -> throw illegal(current, event);
};
case SHIPPED -> switch (event) {
case DELIVER -> OrderState.DELIVERED;
default -> throw illegal(current, event);
};
case DELIVERED, CANCELLED -> throw illegal(current, event);
};
}
private IllegalStateException illegal(OrderState s, OrderEvent e) {
return new IllegalStateException("Нельзя " + e + " из состояния " + s);
}
Плюсы. Ничего не нужно подключать, всё видно в одном месте, читается сверху вниз. Компилятор помогает: если добавить новое состояние в enum, он подсветит switch, где не хватает ветки (при использовании switch без default по всем константам). Для маленького автомата это идеальный вариант.
Минусы. Как только состояний и событий становится много, switch разрастается во вложенную «лесенку», в которой легко ошибиться и трудно увидеть картину целиком. Логика перехода размазана по коду, а не описана как данные — новую связку «состояние → событие» приходится вписывать руками в нужную ветку. Пока переходов десяток — терпимо, дальше становится больно.
Таблица переходов
Следующий шаг — вынести правила из кода в данные. Автомат — это по сути таблица: для пары «состояние + событие» указано новое состояние. Опишем её как Map, а метод перехода станет тривиальным поиском по этой таблице.
public record Transition(OrderState from, OrderEvent event) {}
private static final Map<Transition, OrderState> TABLE = Map.of(
new Transition(OrderState.NEW, OrderEvent.PAY), OrderState.PAID,
new Transition(OrderState.NEW, OrderEvent.CANCEL), OrderState.CANCELLED,
new Transition(OrderState.PAID, OrderEvent.SHIP), OrderState.SHIPPED,
new Transition(OrderState.PAID, OrderEvent.CANCEL), OrderState.CANCELLED,
new Transition(OrderState.SHIPPED, OrderEvent.DELIVER),OrderState.DELIVERED
);
public OrderState next(OrderState current, OrderEvent event) {
OrderState target = TABLE.get(new Transition(current, event));
if (target == null) {
throw new IllegalStateException("Нельзя " + event + " из состояния " + current);
}
return target;
}
Теперь весь автомат виден одним взглядом — это буквально список разрешённых переходов. Чтобы изменить правила, не нужно трогать логику: добавили или убрали строку в таблице — и поведение поменялось. Такую таблицу легко проверить тестом (перебрать все пары и сверить с ожиданием) и даже вынести в конфигурацию, если правила меняются часто.
Тот же автомат как таблица переходов: строка — текущее состояние, столбец — событие, клетка — куда перейти. Подсвечены только пять разрешённых переходов; пустые клетки (—) запрещены. Видно, насколько таблица разрежённая, а конечные состояния DELIVERED и CANCELLED — целиком пустые строки: из них выхода нет.
Когда подходит. Средний по размеру автомат, где переходов много, но каждый из них — просто «смена состояния», без сложной сопутствующей логики. Если же для каждого перехода нужно выполнять разное поведение (отправить письмо, списать деньги, проверить условие), таблица состояние → состояние этого не выражает — и тут напрашивается следующий приём.
State-паттерн (GoF)
Если в каждом состоянии много собственного поведения, разумно сделать состояние отдельным объектом. Это классический паттерн «Состояние» из каталога GoF: есть общий интерфейс состояния с методами-событиями, и по одной реализации на каждое состояние. Каждая реализация сама знает, как отвечать на события, — и куда переходить, и что при этом делать.
public interface OrderStateHandler {
OrderStateHandler pay();
OrderStateHandler ship();
OrderStateHandler cancel();
default OrderStateHandler reject(String action) {
throw new IllegalStateException("Нельзя " + action + " в состоянии " + getClass().getSimpleName());
}
}
public final class NewState implements OrderStateHandler {
@Override
public OrderStateHandler pay() {
// здесь может жить своя логика: списать оплату, записать транзакцию
return new PaidState();
}
@Override
public OrderStateHandler ship() {
return reject("отгрузить");
}
@Override
public OrderStateHandler cancel() {
return new CancelledState();
}
}
Каждый класс отвечает только за одно состояние: видно, какие события оно принимает, а какие отвергает, и вся логика этого состояния собрана рядом. Добавить состояние — значит добавить класс, не трогая остальные. Плата за это — много мелких классов и рассеянность общей картины: чтобы понять весь автомат целиком, нужно открыть все реализации сразу (таблица переходов в этом смысле нагляднее). Поэтому State-паттерн оправдан именно тогда, когда важнее поведение внутри состояний, а не обзор переходов между ними.
Готовая библиотека: Spring Statemachine
Когда автомат большой и обвешан требованиями, свою реализацию поддерживать становится дорого. Тогда берут готовую — например, Spring Statemachine. Она описывает автомат декларативно (состояния, события, переходы) и добавляет то, что вручную писать долго и легко испортить:
- guard-ы — условия, при которых переход разрешён (перейти в
SHIPPED, только если оплата подтверждена); - действия (actions) — код, выполняемый на входе в состояние, на выходе или на переходе (отправить уведомление, создать задачу);
- персистентность — сохранение текущего состояния автомата в БД и восстановление после перезапуска;
- иерархия состояний — вложенные состояния (внутри
SHIPPED— подсостоянияIN_TRANSITиAT_PICKUP_POINT) и параллельные регионы.
Когда брать. Автомат по-настоящему большой, переходов десятки, есть иерархия, нетривиальные guard-ы и действия, состояние нужно надёжно хранить и восстанавливать. Тогда библиотека экономит силы и даёт единообразный каркас.
Когда избыточна. Для автомата из пяти состояний и десятка переходов библиотека — из пушки по воробьям: конфигурация и зависимость перевесят выгоду, а enum+switch или таблица дадут тот же результат меньшими средствами. Есть и более тяжёлые движки — Camunda и другие BPMN-оркестраторы, — но это уже про моделирование бизнес-процессов схемами, а не про компактный автомат внутри одного сервиса; их берут под совсем другой масштаб задач.
Хранение состояния и гонки
Любой из способов выше отвечает на вопрос «куда перейти». Остаётся второй вопрос — где хранить текущее состояние и как не сломать его при одновременных запросах. Обычно состояние живёт в колонке status таблицы заказа:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status VARCHAR(20) NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
Проблема возникает, когда два потока одновременно читают заказ в состоянии PAID и оба решают его обработать: один шлёт SHIP, другой — CANCEL. Каждый по отдельности видит допустимый переход, но вместе они конфликтуют, и без защиты выиграет тот, кто записал последним, затерев чужой результат. Есть два стандартных приёма.
Оптимистичная блокировка. В таблице держат колонку version. При чтении запоминают версию, при записи обновляют строку с условием «версия та же, что была»: UPDATE orders SET status = 'SHIPPED', version = version + 1 WHERE id = ? AND version = ?. Если между чтением и записью кто-то уже поменял строку, версия не совпадёт, обновление затронет 0 строк — и мы понимаем, что случилась гонка. Тогда операцию повторяют заново, уже на актуальном состоянии. В JPA это делает аннотация @Version автоматически.
Пессимистичная блокировка (SELECT ... FOR UPDATE). Второй поток блокируется на чтении строки до тех пор, пока первый не завершит транзакцию. Строку читают под блокировкой, проверяют переход, записывают, коммитят — и только потом её увидит второй.
// в репозитории — заблокировать строку на время транзакции
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from OrderEntity o where o.id = :id")
Optional<OrderEntity> findByIdForUpdate(@Param("id") long id);
Смысл обоих приёмов один: проверка перехода и его запись должны быть одной неделимой операцией, чтобы между «прочитали PAID» и «записали SHIPPED» никто не успел вклиниться. Оптимистичная блокировка дешевле, когда конфликты редки (просто повторяем при неудаче); пессимистичная надёжнее, когда за одну и ту же запись часто конкурируют. Без любой из них корректный на бумаге автомат в реальной работе будет иногда переходить не туда.
Что выбрать
Единственно правильного способа нет — выбор зависит от размера автомата и от того, сколько логики висит на переходах.
- enum + switch — для маленького автомата: несколько состояний, простые переходы, никаких сторонних зависимостей.
- Таблица переходов — для среднего: переходов много, но они сводятся к «сменить состояние»; правила хочется видеть списком и менять, не трогая код.
- State-паттерн — когда в каждом состоянии много собственного поведения и его важно держать вместе, рядом с состоянием.
- Библиотека (Spring Statemachine) — для больших автоматов с guard-ами, действиями, персистентностью и иерархией состояний; для мелких она избыточна.
И независимо от выбора — не забудьте про хранение и гонки: колонка status, а поверх неё оптимистичная или пессимистичная блокировка, чтобы одновременные запросы не сделали конфликтный переход.
Коротко
- Автомат на бумаге ничего не гарантирует — всё решает то, как перенести его в код, чтобы недопустимые переходы стали невозможны.
- enum + switch — простейший вариант: наглядно и без зависимостей, но плохо масштабируется.
- Таблица переходов (
Mapпо паре «состояние + событие») выносит правила в данные: весь автомат виден списком, менять правила легко. - State-паттерн делает каждое состояние объектом со своим поведением — удобно, когда логики внутри состояний много.
- Spring Statemachine берут для больших автоматов с guard-ами, действиями, персистентностью и иерархией; для мелких — избыточна.
- Состояние обычно хранят в колонке
status; чтобы два потока не сделали конфликтный переход, применяют оптимистичную (version) или пессимистичную (SELECT ... FOR UPDATE) блокировку. - Выбор — под размер:
enum+switchдля простого, таблица для среднего, State-паттерн для сложного поведения, библиотека для больших автоматов.