handbookНаписать

patterns/behavioral/state/practice/README.md · практика · 4 языка · 38 тестов

Практика: жизненный цикл заказа (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: int

+amountCents: int

+payments: Payments

-state: OrderState

-history: Transition[]

+place(id, amount, payments) : Order

+restore(id, amount, status, payments) : Order

+pay()

+ship()

+deliver()

+cancel()

+refund()

+status() : OrderStatus

+history() : Transition[]

«abstract»

OrderState

+status() : OrderStatus

+pay(order) : OrderState

+ship(order) : OrderState

+deliver(order) : OrderState

+cancel(order) : OrderState

+refund(order) : OrderState

«interface»

Payments

+charge(orderId, amount)

+refund(orderId, amount)

NewState

PaidState

ShippedState

DeliveredState

FinalState

-status: OrderStatus

Transition

pay / списать

cancel

ship

cancel / вернуть деньги

deliver

refund / вернуть деньги

new

paid

cancelled

shipped

delivered

refunded

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, ни суммы — всё берут из заказа, который приходит параметром.

ЯзыкОсобенность
PHPenum OrderStatus: string и OrderStatus::from() для строки из базы; абстрактный OrderState с запретами по умолчанию; match в restore исчерпывающий
GoЗапреты по умолчанию — встраивание пустой структуры forbidAll; ошибка — *TransitionError для errors.As; плюс таблица transitions и NextStatus
PythonStrEnum и базовый класс с запретами; 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 без default PHPStan проверяет на полноту. Абстрактный класс нужен ради запретов по умолчанию: интерфейс их не даёт.
  • 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.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS