Автомат состояний спроектирован: нарисована диаграмма, известны состояния заказа, события и разрешённые переходы. Но диаграмма на бумаге ничего не гарантирует сама по себе — всё решает то, как вы перенесёте её в код. Легко написать так, что «случайно» окажется возможным перейти из отменённого заказа в оплаченный, или что два потока одновременно сделают взаимоисключающие переходы. Задача — воплотить автомат так, чтобы недопустимые переходы были невозможны, а не просто «не предусмотрены». Способов несколько — от простого 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;
}

Теперь весь автомат виден одним взглядом — это буквально список разрешённых переходов. Чтобы изменить правила, не нужно трогать логику: добавили или убрали строку в таблице — и поведение поменялось. Такую таблицу легко проверить тестом (перебрать все пары и сверить с ожиданием) и даже вынести в конфигурацию, если правила меняются часто.

PAY SHIP DELIVER CANCEL NEW PAID SHIPPED DELIVERED CANCELLED PAID CANCELLED SHIPPED CANCELLED DELIVERED

Тот же автомат как таблица переходов: строка — текущее состояние, столбец — событие, клетка — куда перейти. Подсвечены только пять разрешённых переходов; пустые клетки (—) запрещены. Видно, насколько таблица разрежённая, а конечные состояния 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-паттерн для сложного поведения, библиотека для больших автоматов.