Автомат состояний спроектирован: нарисована диаграмма, известны состояния заказа, события и разрешённые переходы. Но диаграмма на бумаге ничего не гарантирует сама по себе — всё решает то, как вы перенесёте её в код. Легко написать так, что «случайно» окажется возможным перейти из отменённого заказа в оплаченный, или что два потока одновременно сделают взаимоисключающие переходы. Задача — воплотить автомат так, чтобы недопустимые переходы были невозможны, а не просто «не предусмотрены». Способов несколько — от простого 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 IllegalTransitionException illegal(OrderState s, OrderEvent e) {
    return new IllegalTransitionException(s, e);   // доменное исключение, не из JDK
}

Плюсы. Ничего не нужно подключать, всё видно в одном месте, читается сверху вниз. Для маленького автомата это идеальный вариант.

Про помощь компилятора надо сказать точно, потому что работает она только наполовину. Внешний switch (current) перечисляет все константы и обходится без default — добавите в enum шестое состояние, и код перестанет компилироваться, пока вы не опишете, что с ним делать. А вот внутренние switch (event) заканчиваются веткой default, и новое событие тихо провалится туда: компилятор промолчит, ошибка вылезет уже во время работы. Хотите такую же защиту и по событиям — убирайте default из внутренних switch и перечисляйте все события явно, включая запрещённые.

Минусы. Как только состояний и событий становится много, 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 IllegalTransitionException(current, event);
    }
    return target;
}

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

Но у правил-данных есть та же слабость, что у switch с default: забытая пара «состояние + событие» никак себя не проявит — она просто окажется запрещённой, и никто этого не заметит. Поэтому таблицу покрывают тестом на полноту. Идея простая: перебрать все пары и потребовать, чтобы про каждую было принято решение — либо переход есть в таблице, либо она явно перечислена как запрещённая.

private static final Set<Transition> FORBIDDEN = Set.of(
    new Transition(OrderState.NEW, OrderEvent.SHIP),
    new Transition(OrderState.NEW, OrderEvent.DELIVER)
    // ...и так далее — каждый запрет записан осознанно
);

@Test
void everyStateEventPairIsDecided() {
    for (OrderState state : OrderState.values()) {
        for (OrderEvent event : OrderEvent.values()) {
            var pair = new Transition(state, event);
            assertThat(TABLE.containsKey(pair) || FORBIDDEN.contains(pair))
                .as("Не решено, что делать: %s + %s", state, event)
                .isTrue();
        }
    }
}

Добавили новое состояние — тест сразу покраснеет на четырёх непокрытых парах и заставит про каждую подумать. Без такого теста «правила в данных» теряют переходы так же молча, как switch.

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

Тот же автомат как таблица переходов: строка — текущее состояние, столбец — событие, клетка — куда перейти. Подсвечены только пять разрешённых переходов; пустые клетки (—) запрещены. Видно, насколько таблица разрежённая, а конечные состояния DELIVERED и CANCELLED — целиком пустые строки: из них выхода нет.

Когда подходит. Средний по размеру автомат, где переходов много, но каждый из них — просто «смена состояния», без сложной сопутствующей логики. Если же для каждого перехода нужно выполнять разное поведение (отправить письмо, списать деньги, проверить условие), таблица состояние → состояние этого не выражает — и тут напрашивается следующий приём.

Guard: где живёт условие перехода

Условие перехода — это то, что отличает учебный автомат от рабочего: «отменить можно, но только пока не отгружено», «оплатить можно, только если сумма совпадает». В трёх самописных вариантах guard живёт в трёх разных местах, и это как раз та разница, ради которой между ними выбирают.

В перечислении и switch guard — обычное условие внутри ветки:

case PAY -> {
    if (!amount.equals(order.total())) {
        throw new PartialPaymentNotAllowed(order.id());
    }
    yield PAID;
}

Просто и читаемо, пока ветвей мало. Минус в том, что условие и переход смешаны: глядя на код, нельзя отдельно ответить «какие вообще есть правила».

В таблице переходов guard становится полем записи — и вот это самое ценное свойство таблицы, потому что правила остаются данными:

public record Transition(OrderState from, OrderEvent event) {}

public record Rule(OrderState to, Predicate<Order> guard, String rejection) {
    static Rule always(OrderState to) { return new Rule(to, o -> true, null); }
}

private static final Map<Transition, Rule> TABLE = Map.of(
    new Transition(NEW,  PAY),    new Rule(PAID, o -> o.isFullyPaid(), "PARTIAL_PAYMENT"),
    new Transition(NEW,  CANCEL), Rule.always(CANCELLED),
    new Transition(PAID, SHIP),   new Rule(SHIPPED, o -> o.hasStock(), "OUT_OF_STOCK"),
    new Transition(PAID, CANCEL), new Rule(CANCELLED, o -> !o.isShipped(), "ALREADY_SHIPPED")
);

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

В состоянии как классе guard — часть метода того состояния, которое его проверяет: PaidState.ship() сам решает, можно ли. Это самый естественный вариант, когда правил много и они разные для каждого состояния.

Практическое правило: guard, который зависит только от самого объекта, живёт в автомате; guard, которому нужны внешние данные, — нет. «Не отгружать, пока не оплачено» — в автомате. «Не отгружать, если товара нет на складе» — проверка до вызова перехода, потому что для неё нужен запрос в другой контекст, а автомат не должен ходить по сети.

State-паттерн (GoF)

Если в каждом состоянии много собственного поведения, разумно сделать состояние отдельным объектом. Это классический паттерн «Состояние» из каталога GoF: есть общий интерфейс состояния с методами-событиями, и по одной реализации на каждое состояние. Каждая реализация сама знает, как отвечать на события, — и куда переходить, и что при этом делать.

NewState PaidState ShippedState pay() ship() CancelledState cancel()

Каждое состояние тут отдельный класс, а подпись стрелки это метод события: он не меняет поле, а возвращает объект следующего состояния.

public interface OrderStateHandler {
    OrderStateHandler pay();
    OrderStateHandler ship();
    OrderStateHandler cancel();

    default OrderStateHandler reject(String action) {
        throw new IllegalTransitionException(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-паттерн оправдан именно тогда, когда важнее поведение внутри состояний, а не обзор переходов между ними.

Остаётся вопрос, который в описании паттерна обычно опускают: в базе-то лежит не объект, а строка. Мост между ними делают через enum-ключ и фабрику — в колонке status хранится имя состояния, а объект по нему собирают при загрузке:

public enum OrderState {
    NEW(NewState::new),
    PAID(PaidState::new),
    SHIPPED(ShippedState::new),
    DELIVERED(DeliveredState::new),
    CANCELLED(CancelledState::new);

    private final Supplier<OrderStateHandler> factory;

    OrderState(Supplier<OrderStateHandler> factory) { this.factory = factory; }

    public OrderStateHandler handler() { return factory.get(); }
}

Обратный путь нужен тоже, поэтому в интерфейс добавляют метод OrderState state() — каждая реализация возвращает свою константу. Тогда цикл замыкается: загрузили заказ, взяли OrderState.valueOf(row.getStatus()).handler(), вызвали событие, получили новый объект состояния и записали в колонку его state().name(). Сами объекты состояний при этом остаются без полей — всё изменяемое живёт в заказе, а состояние только решает, что с ним можно сделать.

Готовая библиотека: Spring Statemachine

Когда автомат большой и обвешан требованиями, свою реализацию поддерживать становится дорого. Тогда берут готовую — например, Spring Statemachine. Она описывает автомат декларативно (состояния, события, переходы) и добавляет то, что вручную писать долго и легко испортить:

  • guard-ы — условия, при которых переход разрешён (перейти в SHIPPED, только если оплата подтверждена);
  • действия (actions) — код, выполняемый на входе в состояние, на выходе или на переходе (отправить уведомление, создать задачу);
  • персистентность — сохранение текущего состояния автомата в БД и восстановление после перезапуска;
  • иерархия состояний — вложенные состояния (внутри SHIPPED — подсостояния IN_TRANSIT и AT_PICKUP_POINT) и параллельные регионы.

Когда брать. Автомат по-настоящему большой, переходов десятки, есть иерархия, нетривиальные guard-ы и действия, состояние нужно надёжно хранить и восстанавливать. Тогда библиотека экономит силы и даёт единообразный каркас.

Когда избыточна. Для автомата из пяти состояний и десятка переходов библиотека — из пушки по воробьям: конфигурация и зависимость перевесят выгоду, а enum+switch или таблица дадут тот же результат меньшими средствами.

Место Spring Statemachine — ровно посередине: сложнее самописного enum, но всё ещё автомат внутри одного сервиса, где переходы занимают секунды. Как только процесс растягивается на дни, уходит в несколько сервисов и начинает ждать решения человека, библиотека перестаёт справляться — и не из-за размера автомата, а из-за того, чего в ней нет: таймеров «напомнить через три дня», задач для людей, истории прохождения для бизнеса, компенсаций с порядком отката. Это уже территория BPM-движков вроде Camunda: там процесс рисуют схемой, и движок сам ведёт по ней каждую заявку.

Хранение состояния и гонки

Любой из способов выше отвечает на вопрос «куда перейти». Остаётся второй вопрос — где хранить текущее состояние и как не сломать его при одновременных запросах. Обычно состояние живёт в колонке status таблицы заказа:

CREATE TABLE orders (
    id         bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status     text        NOT NULL DEFAULT 'NEW'
                           CHECK (status IN ('NEW','PAID','SHIPPED','DELIVERED','CANCELLED')),
    version    bigint      NOT NULL DEFAULT 0,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

Пара слов про типы, потому что здесь легко сделать по привычке и потом жалеть. text вместо varchar(20): в PostgreSQL они работают одинаково быстро, но у varchar(N) есть ограничение длины, которое однажды придётся менять, а у text — нет. Сам список допустимых значений задаёт CHECK: его видно в схеме, он проверяется базой и правится обычной миграцией. Время — timestamptz, а не timestamp: без зоны момент времени восстановить невозможно, и первый же переезд сервера или переход на летнее время это покажет.

Проблема возникает, когда два потока одновременно читают заказ в состоянии PAID и оба решают его обработать: один шлёт SHIP, другой — CANCEL. Каждый по отдельности видит допустимый переход, но вместе они конфликтуют, и без защиты выиграет тот, кто записал последним, затерев чужой результат. Есть два стандартных приёма.

шаг 1 A читает PAID шаг 1 B читает PAID шаг 2 A пишет SHIPPED шаг 3 B пишет CANCELLED итог SHIPPED потерян

Оба потока прочитали PAID в один момент, и каждый переход по отдельности допустим; смотрите на две нижние строки: запись второго легла поверх первой, и SHIPPED исчез без единой ошибки.

Оптимистичная блокировка. В таблице держат колонку version. При чтении запоминают версию, при записи обновляют строку с условием «версия та же, что была»: UPDATE orders SET status = 'SHIPPED', version = version + 1 WHERE id = ? AND version = ?. Если между чтением и записью кто-то уже поменял строку, версия не совпадёт, обновление затронет 0 строк — и мы понимаем, что случилась гонка. Тогда операцию повторяют заново, уже на актуальном состоянии.

Здесь важно не обмануться формулировкой «в JPA это делает @Version автоматически». Автоматически делается только половина: JPA сама подставит условие по версии и, если строка изменилась, бросит OptimisticLockException. Повтор за вас не сделает никто — его пишут руками. И не внутри той же транзакции: она после исключения уже помечена на откат, и работать в ней нельзя. Повторять надо целиком — открыть новую транзакцию, перечитать заказ, заново проверить переход и записать. Для статьи, где повтор и есть половина решения, это принципиально: @Version обнаруживает гонку, а разбираться с ней — ваша работа.

Пессимистичная блокировка (SELECT ... FOR UPDATE). Второй поток блокируется на чтении строки до тех пор, пока первый не завершит транзакцию. Строку читают под блокировкой, проверяют переход, записывают, коммитят — и только потом её увидит второй.

// persistence/.../JooqOrderRepository.java
public Optional<Order> findById(OrderId id, SelectMode mode) {
    SelectQuery<OrdersRecord> query = dsl.selectFrom(ORDERS)
        .where(ORDERS.ID.eq(id.value()))
        .getQuery();

    if (mode == SelectMode.FOR_UPDATE) {
        query.setForUpdate(true);          // ← SELECT ... FOR UPDATE
    }
    return query.fetchOptional().map(mapper::toDomain);
}

SelectMode — это обычный enum с двумя значениями, READ_ONLY и FOR_UPDATE. Он стоит вторым параметром у каждого метода чтения агрегата, чтобы вызывающий явно сказал, зачем читает: запросу на показ блокировка не нужна, команде, которая сейчас будет менять состояние, — нужна. Подробный разбор — в статье Command side в CQRS.

Смысл обоих приёмов один: проверка перехода и его запись должны быть одной неделимой операцией, чтобы между «прочитали PAID» и «записали SHIPPED» никто не успел вклиниться. Оптимистичная блокировка дешевле, когда конфликты редки (просто повторяем при неудаче); пессимистичная надёжнее, когда за одну и ту же запись часто конкурируют. Без любой из них корректный на бумаге автомат в реальной работе будет иногда переходить не туда.

Действия при переходе: что делать с письмами и деньгами

Все варианты выше отвечают на вопрос «куда перейти» и молчат про то, что в реальном сервисе висит на переходе: списать деньги, отправить письмо, зарезервировать товар, опубликовать событие. Это половина работы, и здесь же живёт самая частая авария: состояние сменилось, а действие не выполнилось — или наоборот.

Первое правило: автомат не выполняет действия, он их объявляет. Метод перехода меняет состояние и возвращает список того, что нужно сделать (или публикует доменное событие). Сам он никуда не ходит: ни в сеть, ни в почту, ни к платёжному провайдеру.

public List<SideEffect> pay(Money amount) {
    if (status != NEW) throw new IllegalTransition(status, PAY);
    if (!amount.equals(total())) throw new PartialPaymentNotAllowed(id);
    status = PAID;
    return List.of(new ReserveStock(id, lines),
                   new NotifyCustomer(id, PAID),
                   new PublishEvent(new OrderPaid(id, amount)));
}

Почему так, а не «отправить письмо прямо здесь»: автомат остаётся проверяемым без всякой инфраструктуры (тест вызывает pay и смотрит, какие действия вернулись), и появляется одно место, где решается, как эти действия выполнить.

Второе правило: что уходит наружу — через таблицу исходящих сообщений. Действие, которое обращается к внешнему миру (письмо, событие в брокер, вызов соседа), нельзя выполнять внутри транзакции перехода: транзакция может откатиться после успешной отправки, и письмо уйдёт про заказ, который не оплачен. И нельзя после коммита: между коммитом и отправкой процесс может умереть, и письмо не уйдёт никогда.

Рабочая схема одна: в той же транзакции, что и переход, записать задание в таблицу, а отправку сделать отдельным процессом.

@Transactional
public void handle(PayOrder command) {
    Order order = orders.findById(command.orderId(), FOR_UPDATE);

    List<SideEffect> effects = order.pay(command.amount());   // переход

    orders.save(order);                                       // состояние
    history.record(order.id(), NEW, PAID, "PAYMENT_RECEIVED", command.actor());
    outbox.saveAll(effects);                                  // задания на потом
}

Одна транзакция, три записи, ни одного обращения наружу. Дальше отправщик забирает задания из таблицы и выполняет их, повторяя при сбоях — и вот здесь нужно третье правило.

Третье правило: действия должны переносить повтор. Отправщик не знает, дошло ли письмо, если оборвалась сеть, — и попробует снова. Значит, у каждого задания есть ключ, а получатель обязан отличить повтор: письмо с тем же ключом не отправляется дважды, резерв с тем же ключом не создаётся дважды. Механика — в статье про идемпотентность, и без неё «надёжная доставка» превращается в «двойное списание».

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

Порядок внутри перехода, который стоит запомнить как последовательность: проверить переход → проверить условия → сменить состояние → записать историю → записать задания → закоммитить. Всё внешнее — за пределами этого списка.

Как тестировать автомат

У автомата есть свойство, которого нет у обычного кода: его правила конечны и перечислимы, а значит, тест может проверить их все. Это пять строк и лучший аргумент в пользу таблицы переходов.

Перебор всех пар «состояние × событие». Тест проходит по декартову произведению и сверяет результат с ожидаемой матрицей:

@ParameterizedTest
@MethodSource("allPairs")
void everyPairBehavesAsSpecified(OrderState from, OrderEvent event) {
    Optional<OrderState> expected = EXPECTED.get(new Transition(from, event));

    Optional<OrderState> actual = StateMachine.next(from, event);

    assertThat(actual).isEqualTo(expected);   // включая «перехода нет»
}

static Stream<Arguments> allPairs() {
    return Arrays.stream(OrderState.values())
            .flatMap(s -> Arrays.stream(OrderEvent.values()).map(e -> arguments(s, e)));
}

Ценность здесь не в том, что проверены разрешённые переходы, а в том, что проверены запрещённые: пять состояний и пять событий дают 25 пар, из которых разрешено шесть — и тест утверждает, что остальные девятнадцать ничего не меняют. Именно эти девятнадцать и есть содержание автомата, и обычными тестами их никто не покрывает.

Проверка достижимости. Второй тест, который ловит ошибки в правилах, а не в коде: обойти граф переходов от начального состояния и убедиться, что каждое состояние достижимо, а из каждого конечного нет выхода.

@Test
void allStatesAreReachableAndTerminalsAreFinal() {
    Set<OrderState> reachable = reachableFrom(OrderState.NEW);

    assertThat(reachable).containsAll(Arrays.asList(OrderState.values()));
    for (OrderState terminal : List.of(DELIVERED, CANCELLED)) {
        assertThat(outgoing(terminal)).isEmpty();
    }
}

Недостижимое состояние — мёртвый код в перечислении (обычно остаток от старого процесса), а конечное состояние с выходом — почти всегда ошибка в правилах, которую в проде обнаружит поддержка.

Тесты на условия и действия. Отдельно: guard проверяется парой тестов «условие выполнено — переход прошёл» и «не выполнено — отказ с нужным кодом». Действия — тем, что метод перехода вернул нужный список (а не тем, что письмо ушло): это как раз та польза от «автомат объявляет действия», о которой раздел выше.

И тест на гонку, если состояние живёт в базе: два потока пытаются перевести один объект, один выигрывает, второй получает понятную ошибку и, при повторе, видит актуальное состояние. Один тест, который окупается на первой же аварии.

Состояние приходит извне: опоздания и повторы

Отдельный класс задач, который ломает всё описанное выше: состояние меняет не ваш код, а внешняя система — перевозчик, платёжный провайдер, партнёр. Приходит вызов от них («посылка вручена»), и переход надо выполнить по чужим данным. Здесь три особенности, и все три встречаются в первый же месяц.

Сообщения приходят не по порядку. Перевозчик отправил «в пути» и «доставлено», а до вас дошло сначала второе. Причины обычные: повторы на их стороне, несколько узлов отправки, сеть. Если применять как пришло, заказ окажется «в пути» после того, как был доставлен.

Правильный ответ — отбрасывать переход «назад», а не пытаться его выполнить. Для этого нужно, чтобы у состояний был порядок или отметка времени события, и правило простое:

public void applyCarrierStatus(CarrierStatus incoming, Instant occurredAt) {
    if (lastCarrierEventAt != null && occurredAt.isBefore(lastCarrierEventAt)) {
        log.info("Опоздавшее событие перевозчика отброшено: {} от {}", incoming, occurredAt);
        return;                                  // не ошибка, штатный случай
    }
    OrderState target = map(incoming);
    if (!canMoveTo(target)) {                    // движение назад по процессу
        log.info("Переход {} -> {} отброшен как обратный", status, target);
        return;
    }
    status = target;
    lastCarrierEventAt = occurredAt;
}

Две вещи в этом коде важнее остального. Первое: опоздавшее событие — не ошибка, и на него нельзя отвечать исключением: внешняя система воспримет ошибку как «не доставлено» и будет повторять бесконечно. Отвечать надо успехом и ничего не делать. Второе: сравнение по времени события, а не по времени получения — именно поэтому время нужно брать из полезной нагрузки, и если внешняя система его не присылает, это первое, о чём стоит попросить.

Повторы приходят всегда. Внешняя система, не получившая подтверждения, отправит то же самое снова — иногда десятки раз. Значит, обработчик обязан быть идемпотентным: тот же переход, применённый дважды, не должен порождать двойных действий (двух писем, двух возвратов). Практически — отметка обработанных сообщений по идентификатору от внешней системы, в той же транзакции, что и переход.

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

Итого минимум для такого обработчика: время события в данных, отбрасывание обратных и опоздавших переходов без ошибки, идемпотентность по идентификатору сообщения, явная таблица соответствия и безопасное поведение при неизвестном статусе.

Новое состояние в работающей системе

Добавить состояние в перечисление — одна строка. Проблема в том, что в базе уже лежат миллионы записей со старыми значениями, а рядом работают копии сервиса с предыдущей версией кода. Порядок, который не ломает прод.

Добавление состояния — совместимое изменение, если делать в правильном порядке. Сначала база, потом код, потом использование:

  1. Ограничение в базе разрешает новое значение — миграция меняет проверку списка допустимых значений (или добавляет значение в тип). Выполняется до выката кода; старый код нового значения не пишет и не читает, поэтому ему это не мешает.
  2. Код умеет читать новое состояние, но ещё не переводит в него. Выкатывается; теперь все копии понимают новое значение.
  3. Включается переход — новым выкатом или флагом. Только теперь в базе появляются записи с новым состоянием, и все копии к этому готовы.

Почему порядок именно такой: при выкате по одной копии старая версия обязана понимать то, что пишет новая. Пропустили шаг 2 — старая копия встретит незнакомое значение и упадёт на разборе перечисления. Это то же правило совместимости, что для любой схемы данных: сначала научиться читать, потом начать писать.

Удаление состояния — обратный порядок и гораздо дольше. Перестать переводить → дождаться, пока записей в этом состоянии не останется (а они могут висеть месяцами: незакрытые заказы, забытые заявки) → убрать из кода → убрать из ограничения. Практически последний шаг часто не делают вовсе, и это нормально: лишнее значение в проверке никому не мешает, а код, который его не знает, — мешает.

Что делать со старыми записями, если смысл состояния изменился. Худший случай: было одно состояние «в обработке», стало три. Старые записи надо куда-то отнести, и вариантов два. Оставить как есть (старое значение остаётся допустимым для истории, новые пишутся новыми значениями) — просто, честно, но отчёты теперь должны знать про оба. Перелить миграцией — заказы в старом состоянии распределить по новым, если это возможно по другим полям; и вот здесь нужна осторожность, потому что распределение может оказаться неоднозначным, а миграция необратима.

Чего нельзя делать ни в каком порядке: переименовывать значение (это одновременно удаление и добавление, и при выкате по одной копии часть записей окажется с одним именем, часть с другим), менять смысл существующего значения (старые записи начнут врать), и добавлять состояние в середину линейного процесса без проверки, что код, читающий «старые» записи, к этому готов.

Тест, который это страхует. Прогнать тесты предыдущей версии кода против базы с новой миграцией: зелёные — изменение совместимо, красные — при выкате по одной копии будет авария. Это дешёвая проверка, и она же ловит забытый шаг 2.

Что выбрать

Единственно правильного способа нет — выбор зависит от размера автомата и от того, сколько логики висит на переходах.

  • enum + switch — для маленького автомата: несколько состояний, простые переходы, никаких сторонних зависимостей.
  • Таблица переходов — для среднего: переходов много, но они сводятся к «сменить состояние»; правила хочется видеть списком и менять, не трогая код.
  • State-паттерн — когда в каждом состоянии много собственного поведения и его важно держать вместе, рядом с состоянием.
  • Библиотека (Spring Statemachine) — для больших автоматов с guard-ами, действиями, персистентностью и иерархией состояний; для мелких она избыточна.

Те же четыре варианта по признакам — чтобы выбирать не на слух, а по своей задаче:

Признакenum + switchТаблица переходовСостояние как классБиблиотека
Число состоянийдо 55–203–10больше 10
Число переходовдо 10десяткидесяткидесятки и больше
Условия переходавнутри веткиполем записивнутри состоянияотдельным механизмом
Много поведения в состояниинетнетдада
Правила нужны как данные (менять, рисовать, проверять)нетданетда
Подсостояния и параллельные веткинетнетс трудомда
Хранение состояния между запросамисвоими рукамисвоими рукамисвоими рукамивстроено
Сторонняя зависимостьнетнетнетда
Цена входаминутнаячасоваячасоваядни

Как читать эту таблицу: два признака решают почти всегда. Если в состояниях много собственного поведения — состояние как класс, независимо от их числа. Если правила должны быть данными (их правят не только разработчики, по ним строят схему, их проверяют перебором) — таблица переходов. Всё остальное при пяти состояниях — перечисление и switch, и это честный выбор, а не упрощение.

Библиотеку берут по одному настоящему признаку: нужны подсостояния, параллельные ветки или готовое хранение состояния процесса. Если нужны только переходы и условия, она добавляет зависимость и словарь, не добавляя возможностей. И отдельно: когда процесс живёт днями, проходит через несколько систем и требует ожидания внешних событий, речь уже не про автомат в объекте, а про координатор процесса — про это статья про движки процессов.

И независимо от выбора — не забудьте про хранение и гонки: колонка status, а поверх неё оптимистичная или пессимистичная блокировка, чтобы одновременные запросы не сделали конфликтный переход.

Дополнительно: при первом чтении можно пропустить

Глубже: журнал переходов: кто, когда, откуда и почемурасширенное

Первая статья раздела называет аудит переходов признаком, что пора вводить автомат, а таблица выше хранит только status и version. Колонка status отвечает на вопрос «где заказ сейчас» и молчит про «как он сюда попал», а поддержка и разбор инцидентов спрашивают именно это.

Журнал это отдельная таблица только на добавление:

CREATE TABLE order_status_history (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id      uuid        NOT NULL,
    from_status   text,
    to_status     text        NOT NULL,
    occurred_at   timestamptz NOT NULL,
    actor         text        NOT NULL,
    reason        text,
    correlation_id text
);
CREATE INDEX ON order_status_history (order_id, occurred_at);

Строка пишется в той же транзакции, что и обновление status, иначе журнал расходится с состоянием при откате; actor это кто перевёл (пользователь, сотрудник, система по таймеру, обработчик события), reason это почему (код причины отмены, текст от сотрудника), correlation_id связывает переход с запросом или сообщением, из которого он пришёл. Обновлять и удалять строки нельзя, и это закрепляют правами на таблицу, как у журнала аудита в разделе безопасности.

Читают его два потребителя. Поддержка через экран «история заказа», где видно, что заказ отменила система по истечении резерва, а не клиент, и это половина обращений. И разбор аварий: по журналу за час видно, сколько заказов перешло в «отказ оплаты» и когда началось, без поиска по логам. Тот же журнал становится источником метрик: время между переходами это длительность этапов, а число переходов в отказные состояния это сигнал тревоги.

Если агрегат публикует доменные события на каждый переход, журнал это их проекция, и отдельная таблица всё равно нужна: события уезжают в брокер и живут там неделю, а история заказа нужна годами. Писать её из того же места, где меняется статус, дешевле, чем восстанавливать.

Глубже: вложенные и параллельные состояния: заказ из трёх посылокрасширенное

Пример с заказом в разделе выше держит одно состояние. Реальный заказ из трёх посылок ломает картинку: у каждой посылки свой путь (собрана, передана, в пути, доставлена), а у заказа статус «частично отгружен», который не переход, а следствие состояний посылок. Втиснуть это в один enum заказа даёт комбинаторику вроде SHIPMENT1_DELIVERED_SHIPMENT2_IN_TRANSIT, и её никто не поддерживает.

Вложенные автоматы. У каждой посылки свой автомат с своим status и своим журналом; заказ хранит ссылку на посылки, а его статус выводится правилом: все посылки доставлены, заказ доставлен; хоть одна в пути, заказ частично отгружен; все отменены, отменён. Статус заказа при этом либо не хранят вовсе, а вычисляют при чтении, либо хранят как производное и пересчитывают в той же транзакции, что и переход посылки, с тестом на каждое правило вывода. Это тот же принцип, что у агрегатов: посылка меняется независимо, заказ отражает итог.

Параллельные области. Другой случай, когда у одного объекта две независимые оси: оплата (ожидает, оплачен, возвращён) и исполнение (собирается, отгружен, доставлен) у заказа. Это не одно состояние, а два, и хранят их двумя колонками с двумя таблицами переходов; правила, которые связывают оси («отгружать только оплаченный»), живут как условия переходов одной оси, читающие другую. Одна колонка status для двух осей даёт то же произведение состояний, что и выше.

Spring Statemachine умеет и вложенные состояния, и параллельные области в одной конфигурации, и для автомата, который правда один объект с внутренней структурой, это уместно. Но заказ из посылок это не один автомат, а несколько, и вывод статуса правилом проще любой иерархии: его читает любой разработчик, тестируется таблицей случаев и не требует библиотеки. Библиотеку берут, когда области живут внутри одного объекта и переходы между ними нужно координировать событиями внутри автомата.

Коротко

  • Автомат на бумаге ничего не гарантирует — всё решает то, как перенести его в код, чтобы недопустимые переходы стали невозможны. enum + switch — простейший вариант: наглядно и без зависимостей, но плохо масштабируется.
  • Таблица переходов (Map по паре «состояние + событие») выносит правила в данные: весь автомат виден списком, менять правила легко. State-паттерн делает каждое состояние объектом со своим поведением — удобно, когда логики внутри состояний много.
  • Spring Statemachine берут для больших автоматов с guard-ами, действиями, персистентностью и иерархией; для мелких — избыточна. Состояние обычно хранят в колонке status; чтобы два потока не сделали конфликтный переход, применяют оптимистичную (version) или пессимистичную (SELECT ... FOR UPDATE) блокировку.
  • Выбор — под размер: enum+switch для простого, таблица для среднего, State-паттерн для сложного поведения, библиотека для больших автоматов. Журнал переходов это таблица только на добавление, записанная той же транзакцией: откуда, куда, когда, кто, почему, корреляция; читают поддержка и разбор аварий, из него же метрики этапов.
  • Заказ из посылок это вложенные автоматы со статусом заказа, выведенным правилом; независимые оси (оплата и исполнение) это параллельные области двумя колонками; одна колонка на всё даёт произведение состояний.
  • Guard живёт в трёх местах по-разному: условием в ветке, полем записи в таблице (тогда правила остаются данными) или методом состояния; guard, которому нужны внешние данные, проверяют до вызова перехода.
  • Автомат не выполняет действия, а объявляет их: состояние, история и задания пишутся одной транзакцией, наружу уходит отправщик из таблицы исходящих, а действия обязаны переносить повтор; внешний вызов — до транзакции или после, но не внутри.
  • Автомат проверяется перебором всех пар «состояние × событие» (ценны именно запрещённые), тестом достижимости состояний и конечности терминальных, тестами условий и тестом на гонку.
  • Состояние извне: время события в данных, опоздавшие и обратные переходы отбрасывать без ошибки, идемпотентность по идентификатору сообщения, явная таблица соответствия и безопасное поведение при неизвестном чужом статусе.
  • Новое состояние вводят в три шага: разрешить в базе, научить код читать, включить переход; удаление — в обратном порядке и дольше; переименование и смена смысла значения запрещены, а страхует это прогон тестов предыдущей версии.

Что пощупать

Самая простая реализация из статьи, перечисление со switch, работает в сервисе платежей практикума remodov/marketplace-system: Status.canMoveTo перечисляет разрешённое, Payment.moveTo бросает доменное исключение на запрещённом переходе, а повторный возврат обрабатывается в сервисе как безопасный повтор, а не разрешается в автомате. Хранение — голый JdbcTemplate, чтобы автомат и хранение не смешивались.

Код: payment, PaymentTransitionsTest.

Сделаем сами

Ветка step-11-saga-and-state-machine — canMoveTo пустой, повторная авторизация и безопасный возврат не сделаны, четыре проверки красные.

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