Автомат состояний спроектирован: нарисована диаграмма, известны состояния заказа, события и разрешённые переходы. Но диаграмма на бумаге ничего не гарантирует сама по себе — всё решает то, как вы перенесёте её в код. Легко написать так, что «случайно» окажется возможным перейти из отменённого заказа в оплаченный, или что два потока одновременно сделают взаимоисключающие переходы. Задача — воплотить автомат так, чтобы недопустимые переходы были невозможны, а не просто «не предусмотрены». Способов несколько — от простого 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.
Тот же автомат как таблица переходов: строка — текущее состояние, столбец — событие, клетка — куда перейти. Подсвечены только пять разрешённых переходов; пустые клетки (—) запрещены. Видно, насколько таблица разрежённая, а конечные состояния 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: есть общий интерфейс состояния с методами-событиями, и по одной реализации на каждое состояние. Каждая реализация сама знает, как отвечать на события, — и куда переходить, и что при этом делать.
Каждое состояние тут отдельный класс, а подпись стрелки это метод события: он не меняет поле, а возвращает объект следующего состояния.
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. Каждый по отдельности видит допустимый переход, но вместе они конфликтуют, и без защиты выиграет тот, кто записал последним, затерев чужой результат. Есть два стандартных приёма.
Оба потока прочитали 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;
}
Две вещи в этом коде важнее остального. Первое: опоздавшее событие — не ошибка, и на него нельзя отвечать исключением: внешняя система воспримет ошибку как «не доставлено» и будет повторять бесконечно. Отвечать надо успехом и ничего не делать. Второе: сравнение по времени события, а не по времени получения — именно поэтому время нужно брать из полезной нагрузки, и если внешняя система его не присылает, это первое, о чём стоит попросить.
Повторы приходят всегда. Внешняя система, не получившая подтверждения, отправит то же самое снова — иногда десятки раз. Значит, обработчик обязан быть идемпотентным: тот же переход, применённый дважды, не должен порождать двойных действий (двух писем, двух возвратов). Практически — отметка обработанных сообщений по идентификатору от внешней системы, в той же транзакции, что и переход.
Чужие состояния не совпадают с вашими. У перевозчика двадцать статусов, у вас четыре. Отображение чужого на своё — это решение, и его записывают явно, отдельной таблицей соответствия; заодно в ней видно, какие чужие статусы вы игнорируете. И отдельный случай: неизвестный чужой статус (перевозчик добавил новый). Правильное поведение — записать в журнал, вернуть успех и не менять состояние, а не упасть: иначе один новый статус у партнёра остановит обработку всех уведомлений.
Итого минимум для такого обработчика: время события в данных, отбрасывание обратных и опоздавших переходов без ошибки, идемпотентность по идентификатору сообщения, явная таблица соответствия и безопасное поведение при неизвестном статусе.
Новое состояние в работающей системе
Добавить состояние в перечисление — одна строка. Проблема в том, что в базе уже лежат миллионы записей со старыми значениями, а рядом работают копии сервиса с предыдущей версией кода. Порядок, который не ломает прод.
Добавление состояния — совместимое изменение, если делать в правильном порядке. Сначала база, потом код, потом использование:
- Ограничение в базе разрешает новое значение — миграция меняет проверку списка допустимых значений (или добавляет значение в тип). Выполняется до выката кода; старый код нового значения не пишет и не читает, поэтому ему это не мешает.
- Код умеет читать новое состояние, но ещё не переводит в него. Выкатывается; теперь все копии понимают новое значение.
- Включается переход — новым выкатом или флагом. Только теперь в базе появляются записи с новым состоянием, и все копии к этому готовы.
Почему порядок именно такой: при выкате по одной копии старая версия обязана понимать то, что пишет новая. Пропустили шаг 2 — старая копия встретит незнакомое значение и упадёт на разборе перечисления. Это то же правило совместимости, что для любой схемы данных: сначала научиться читать, потом начать писать.
Удаление состояния — обратный порядок и гораздо дольше. Перестать переводить → дождаться, пока записей в этом состоянии не останется (а они могут висеть месяцами: незакрытые заказы, забытые заявки) → убрать из кода → убрать из ограничения. Практически последний шаг часто не делают вовсе, и это нормально: лишнее значение в проверке никому не мешает, а код, который его не знает, — мешает.
Что делать со старыми записями, если смысл состояния изменился. Худший случай: было одно состояние «в обработке», стало три. Старые записи надо куда-то отнести, и вариантов два. Оставить как есть (старое значение остаётся допустимым для истории, новые пишутся новыми значениями) — просто, честно, но отчёты теперь должны знать про оба. Перелить миграцией — заказы в старом состоянии распределить по новым, если это возможно по другим полям; и вот здесь нужна осторожность, потому что распределение может оказаться неоднозначным, а миграция необратима.
Чего нельзя делать ни в каком порядке: переименовывать значение (это одновременно удаление и добавление, и при выкате по одной копии часть записей окажется с одним именем, часть с другим), менять смысл существующего значения (старые записи начнут врать), и добавлять состояние в середину линейного процесса без проверки, что код, читающий «старые» записи, к этому готов.
Тест, который это страхует. Прогнать тесты предыдущей версии кода против базы с новой миграцией: зелёные — изменение совместимо, красные — при выкате по одной копии будет авария. Это дешёвая проверка, и она же ловит забытый шаг 2.
Что выбрать
Единственно правильного способа нет — выбор зависит от размера автомата и от того, сколько логики висит на переходах.
- enum + switch — для маленького автомата: несколько состояний, простые переходы, никаких сторонних зависимостей.
- Таблица переходов — для среднего: переходов много, но они сводятся к «сменить состояние»; правила хочется видеть списком и менять, не трогая код.
- State-паттерн — когда в каждом состоянии много собственного поведения и его важно держать вместе, рядом с состоянием.
- Библиотека (Spring Statemachine) — для больших автоматов с guard-ами, действиями, персистентностью и иерархией состояний; для мелких она избыточна.
Те же четыре варианта по признакам — чтобы выбирать не на слух, а по своей задаче:
| Признак | enum + switch | Таблица переходов | Состояние как класс | Библиотека |
|---|---|---|---|---|
| Число состояний | до 5 | 5–20 | 3–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 пустой, повторная авторизация и безопасный возврат не сделаны, четыре проверки красные.
Что почитать дальше
- Что такое конечный автомат — состояния, переходы и когда автомат нужен.
- BPM-движки и оркестрация — долгие процессы через сервисы, сага и хореография.
- Аудит административных команд — журнал только на добавление и права на таблицу.