Enum в Python: как запретить неправильный переход состояния

У заказа есть NEW, PAID и SHIPPED, поэтому строковые статусы заменили на Enum. Но команда отправки всё ещё может перевести новый неоплаченный заказ прямо в SHIPPED. Ограничение набора значений не ограничивает допустимые переходы.

Разделить словарь состояний и правила движения

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

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

Проверять до присваивания

Метод move сначала читает допустимые варианты, затем меняет поле. При отказе состояние должно сохраниться. Это особенно удобно проверять на одном объекте: запрет отправки из NEW не должен мешать последующей правильной цепочке NEW → PAID → SHIPPED.

Исключение сообщает исходный и целевой статус. Для вызывающего кода это объясняет причину отказа без знания внутренней таблицы. Наружный ввод вроде строки "paid" можно преобразовать через Status(value) на отдельной границе, где неизвестное значение даст ValueError ещё до выполнения команды.

from enum import Enum

class Status(Enum):
    NEW = "new"
    PAID = "paid"
    SHIPPED = "shipped"

ALLOWED = {
    Status.NEW: frozenset({Status.PAID}),
    Status.PAID: frozenset({Status.SHIPPED}),
    Status.SHIPPED: frozenset(),
}

class Order:
    def __init__(self):
        self.status = Status.NEW
    def move(self, target):
        if target not in ALLOWED[self.status]:
            raise ValueError(f"{self.status.value} -> {target.value} forbidden")
        self.status = target

order = Order()
try:
    order.move(Status.SHIPPED)
except ValueError:
    pass
else:
    raise AssertionError("unpaid shipping accepted")
assert order.status is Status.NEW
order.move(Status.PAID)
order.move(Status.SHIPPED)
assert order.status is Status.SHIPPED
assert Status("paid") is Status.PAID
print(order.status.value)

Когда таблицы уже недостаточно

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

Если объект хранится вне процесса, конкурентные изменения требуют проверки версии при сохранении. Локальная таблица переходов не защищает от двух работников, которые прочитали один исходный статус. Сначала правильно сформулируйте правило в объекте, затем обеспечьте его применение на границе хранения.

Начать с объекта, который защищает своё поведение

В открытом уроке о классе, атрибутах и методах потренируйте хранение состояния и вызов действий. Добавьте к своему объекту проверяемый переход по примеру статьи. Откройте программу курса: дальше предусмотрены инварианты и Enum.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

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