Практика: жизненный цикл заказа (Order lifecycle)
Заказ проходит путь от «новый» до «доставлен», «отменён» или «возврат»: каждый статус решает, какие события допустимы, что сделать с деньгами на переходе и как объяснить отказ.
Базовый пример: Состояние. Там недопустимая кнопка просто ничего не делает, состояние живёт только в памяти, а переходов три. В заказе всё строже: запрещённый переход — ошибка с понятным текстом, переход двигает деньги, статус хранится в базе строкой, а история переходов нужна поддержке.
Задача
Интернет-магазин ведёт заказ по статусам: новый, оплачен, отправлен, доставлен, отменён, возврат. Сначала статус был полем, и проверки жили в контроллерах: «отправить можно, если status == 'paid'». Потом появилась отмена, и выяснилось, что отменять оплаченный заказ без возврата денег нельзя, а отправленный — нельзя вовсе. Потом пришёл возврат после доставки, и поддержка попросила видеть, кто и когда перевёл заказ.
Без приёма правила расползаются: проверка статуса в одном месте, списание в другом, запись в историю в третьем. Один забытый if — и отменённый заказ уезжает к клиенту или деньги за отмену не возвращаются.
Что урезано
- Деньги — целые копейки, одна валюта, сумма заказа не меняется.
- Платёжный шлюз — интерфейс
Paymentsс двумя методами; в тестах его заменяет фейк, который записывает вызовы и умеет отказать в возврате. - Хранилище — нет:
restoreпринимает статус строкой, как он пришёл бы из колонкиorders.status, а история не сохраняется. - Нет пользователей, времени перехода, частичных возвратов и транзакций БД вокруг перехода.
Решение
Статус — не поле, а объект-состояние. Базовое OrderState запрещает все пять событий (pay, ship, deliver, cancel, refund) и бросает IllegalTransition с текстом order 42: cannot ship from new. Каждое состояние переопределяет только то, что разрешено, поэтому список разрешённого виден в классе целиком, а запрет — поведение по умолчанию: новое событие ничего не открывает, пока его не разрешат явно.
Действия на переходе живут в том состоянии, откуда уходит заказ: NewState::pay списывает деньги, PaidState::cancel и DeliveredState::refund возвращают их. Отмена нового заказа деньги не трогает — платить ещё нечего. Если шлюз отказал, исключение вылетает до смены состояния: заказ остаётся оплаченным, история не пополняется.
Order делегирует событие состоянию и записывает Transition «откуда, событие, куда» в историю — одна точка, через которую проходит каждый переход.
Структура
Order открывает состояниям id, amountCents и payments — этого достаточно для действий. Пять методов событий одинаковы: спросить у состояния следующее и передать его в apply, который пишет историю и меняет состояние. Порядок важен: сначала состояние отработало (и, может быть, упало), потом запись. restore превращает строку из базы в состояние через исчерпывающий match: статус без состояния не пройдёт PHPStan (PHP) и tsc (TS, через Record<Status, ...>), а строка, которой нет среди статусов, падает при загрузке, а не при первом событии.
Таблица переходов
Ту же машину можно записать данными: «статус -> событие -> следующий статус». В Go и TypeScript таблица лежит рядом с состояниями (transitions и NextStatus/nextStatus), и тест сверяет обе версии по одной и той же таблице ожиданий.
| Вопрос | Состояния-классы | Таблица переходов |
|---|---|---|
| Где видна вся машина | в тесте или на диаграмме | в одном литерале |
| Действия на переходе | метод состояния, рядом с правилом | отдельная колонка с функциями или switch рядом |
| Новое событие | метод в базе (запрет) и в разрешающих состояниях | ключ в нужных строках |
| Можно хранить в конфиге или БД | нет | да |
| Поведение, разное по статусам, кроме переходов | естественно ложится в класс | расползается по if |
Вывод честный: пока переходы — просто разрешено или нет, таблица короче и нагляднее. Состояния-классы окупаются, когда у статуса появляется своё поведение: действия на переходах, свои проверки, свои данные.
Как это собирается
Переход запускает обработчик запроса - здесь панель поддержки. Он читает строку из таблицы orders, собирает заказ через Order::restore со шлюзом платежей из контейнера и нажимает кнопку, которую выбрал сотрудник. Обработчик не проверяет статус сам: можно ли отменить или вернуть деньги, решает состояние, а шлюз дёргается только на разрешённом переходе.
Обработчик различает две ошибки. Запрещённый переход (cancel у отправленного заказа) - обычная ситуация: панель показывает текст IllegalTransition, а не ошибку сервера. Строка с незнакомым статусом lost падает ещё при загрузке, и заказ пропускается до разбора. В конце видно, что шлюз вызвали ровно дважды: списание за оплату и возврат за доставленный заказ.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Состояния ничего не хранят, кроме конечного: FinalState один на «отменён» и «возврат», потому что после них запрещено всё. Остальные не знают ни id, ни суммы — всё берут из заказа, который приходит параметром.
| Язык | Особенность |
|---|---|
| PHP | enum OrderStatus: string и OrderStatus::from() для строки из базы; абстрактный OrderState с запретами по умолчанию; match в restore исчерпывающий |
| Go | Запреты по умолчанию — встраивание пустой структуры forbidAll; ошибка — *TransitionError для errors.As; плюс таблица transitions и NextStatus |
| Python | StrEnum и базовый класс с запретами; OrderStatus("lost") сам бросает ValueError; match в state_of |
| TypeScript | Состояния — объекты: { ...forbidAll("paid"), ship: ... } перекрывает запреты спредом; Record<Status, ...> требует состояние для каждого статуса; плюс таблица nextStatus |
Запуск: make test T=patterns/behavioral/state/practice
Что проверяют тесты
Тесты одинаковы во всех языках.
- Каждая пара «статус x событие». Таблица ожиданий перечисляет только разрешённые переходы; тест обходит все 6 x 5 = 30 пар. Разрешённая пара даёт нужный статус, запрещённая — ошибку с точным текстом
order 42: cannot <событие> from <статус>и статус без изменений. Новое событие или статус, забытый в коде, сразу роняет тест. В Go и TS тот же цикл проверяет и таблицу переходов. - Деньги на переходах. Отмена нового заказа ничего не возвращает, отмена оплаченного — возвращает 5000, отправка денег не трогает, возврат после доставки возвращает.
- История. После
payиcancelв ней ровно два перехода; запрещённыйshipследом историю не пачкает. - Отказ шлюза. Возврат не прошёл — заказ остался оплаченным, история пуста.
- Неизвестный статус в базе.
restoreсо строкойlostпадает сразу.
Где граница
- Переходы — только «разрешено или нет», действий нет — берите таблицу, классы избыточны.
- Статусы и правила настраивают не программисты (свой цикл для каждого типа товара) — таблица уезжает в конфиг или БД, а действия регистрируются по имени перехода, как стратегии в практике Стратегии.
- Переходы с условиями («отмена только в первые 30 минут»), таймерами и параллельными ветками — это уже конечный автомат с guard-условиями или workflow-движок.
- Нужна полная история и восстановление на любой момент — история переходов превращается в журнал событий, и заказ собирается из них (event sourcing).
- От Стратегии отличается тем, кто меняет объект: здесь следующее состояние выбирает текущее, а не вызывающий код — см. Стратегию.
Отличия между языками
- PHP —
enumзакрывает строку из базы:OrderStatus::from('lost')бросаетValueErrorбез нашего кода, аmatchпо enum безdefaultPHPStan проверяет на полноту. Абстрактный класс нужен ради запретов по умолчанию: интерфейс их не даёт. - Go — наследования нет, его роль играет встраивание:
struct{ forbidAll }получает все пять запретов, а свои методы перекрывают их. Типы состояний неэкспортируемые: снаружи видны только интерфейсOrderState, статусы и события. Ошибки — значения; запрет и отказ шлюза различаются черезerrors.Asиerrors.Is. - Python — базовый класс с запретами и
StrEnum, который сравнивается со строкой напрямую (order.status == "paid"). Порядокself._apply("pay", self._state.pay(self))гарантирует, что упавшее действие не попадёт в историю. - TypeScript — состояние — объект, поэтому «наследование запретов» — это спред
...forbidAll(status).Record<Status, OrderState>не даст забыть статус, аObject.hasOwnпревращает строку из базы вStatusчерез type guard.
Связанные темы
- Состояние (базовый пример)
- Стратегия — та же схема делегирования, но объект выбирает клиент
- Практика Стратегии: расчёт доставки — реестр реализаций по коду из конфига