---
title: "State machines в Rails: как объект проходит жизненный цикл"
date: 2026-08-06T12:00:00
tags: [rails, ruby, state-machines, backend, sidekiq, tech]
---

![State machines в Rails](img/state-machines-rails-preview.jpg)

Статья для тех, кто не писал на Ruby, но хочет понять, как в Rails-приложениях описывают жизненный цикл бизнес-объектов и почему это почти всегда конечный автомат.

Примеры упрощены до сути. Домен взят самый понятный - заказ в интернет-магазине.

<!--truncate-->

## Начнём с боли

Почти в каждом приложении есть объект, который живёт не мгновение, а неделями. Заказ. Подписка. Заявка на отпуск. Статья в редакции. Такой объект проходит через фазы, и в базе для этого заводят колонку `status` или `state`.

Первая версия кода всегда выглядит так:

```ruby
order.state = 'complete'
order.save
```

Работает. Проблемы приходят позже, и их три.

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

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

**Третья: непонятно, откуда объект пришёл.** Строка `'canceled'` в базе не хранит, была отмена до оплаты или после. А бизнес это различает.

Конечный автомат (finite state machine, FSM) - это способ записать все три вещи в одном месте. Он состоит из четырёх понятий:

- **состояния** - конечный список фаз;
- **события** - действия, запускающие смену фазы;
- **переходы** - правила «из состояния A по событию X можно попасть в B»;
- **реакции** - код, выполняемый в момент перехода.

Вот как выглядит жизненный цикл заказа, который мы будем разбирать дальше:

```mermaid
stateDiagram-v2
    [*] --> cart
    cart --> payment: checkout
    payment --> complete: pay
    cart --> canceled: cancel
    payment --> canceled: cancel
    complete --> canceled: cancel if cancelable?
    complete --> [*]
    canceled --> [*]
```

Дальше по тексту - как это выглядит в Rails, что происходит под капотом и где на этом обычно обжигаются.

## Три способа хранить состояние в Rails

Прежде чем брать гем, стоит знать альтернативы. Их фактически три.

**Строковая колонка и константы.** Самый простой вариант, никаких зависимостей.

```ruby
class Order < ApplicationRecord
  STATES = %w[cart address payment complete canceled].freeze
  validates :state, inclusion: { in: STATES }
end
```

Даёт список допустимых значений, но не даёт ни правил перехода, ни реакций. Годится, если фаз три и переходы очевидны.

**`enum` из самого Rails.** Встроенный механизм, появился в Rails 4.1.

```ruby
class Order < ApplicationRecord
  enum state: { cart: 0, payment: 1, complete: 2, canceled: 3 }
end
```

Вы бесплатно получаете `order.complete?`, `order.complete!`, скоуп `Order.complete` и красивый маппинг «символ в коде - число в базе» ([документация](https://api.rubyonrails.org/classes/ActiveRecord/Enum.html)). Но `enum` знает только про значения. Он с удовольствием переведёт заказ из `cart` сразу в `complete`, потому что понятия «переход» у него нет.

**Гем с конечным автоматом.** Тут есть из чего выбирать, и выбор влияет на то, как будет выглядеть весь код модели:

| Гем | Чем интересен |
|---|---|
| [`state_machines-activerecord`](https://github.com/state-machines/state_machines-activerecord) | Наследник исторического `state_machine`, самого первого популярного гема в этой нише. Богатый DSL, состояние в колонке модели. Часто встречается в старых проектах и в Spree/Solidus. |
| [`aasm`](https://github.com/aasm/aasm) | Сегодня самый распространённый выбор для нового кода. Тот же набор идей, чуть более лаконичный синтаксис, живая поддержка. |
| [`statesman`](https://github.com/gocardless/statesman) | От платёжной компании GoCardless. Главное отличие: каждый переход пишется отдельной строкой в таблицу переходов, а текущее состояние - это последняя запись. История изменений получается бесплатно, что ценно в финансовых системах. |
| [`workflow`](https://github.com/geekq/workflow) | Ветеран, компактный и с минимумом магии. Встречается редко, но код читается легко. |

Дальше я показываю синтаксис [`state_machines`](https://github.com/state-machines/state_machines), потому что он самый «разговорчивый» и на нём лучше видно устройство DSL. Всё сказанное переносится на `aasm` почти дословно - меняются только имена ключевых слов.

| Задача | `enum` | гем-автомат |
|---|---|---|
| Список допустимых значений | да | да |
| Методы-вопросы `complete?` | да | да |
| Скоупы `Order.complete` | да | да (в `state_machines` тоже есть) |
| Правила «откуда куда можно» | **нет** | да |
| Условия на переход | нет | да |
| Код на момент перехода | нет (только общие callbacks модели) | да |

Практический совет: если у объекта есть слово «нельзя» в описании («нельзя отменить после отправки») - берите автомат. Если состояние это просто метка для фильтра в списке - хватит `enum`.

## Где физически живёт состояние

Здесь никакой магии, и это важно понять сразу. Состояние - обычный столбец:

```sql
CREATE TABLE orders (
  id bigserial PRIMARY KEY,
  state character varying(255),
  ...
);
```

Гем читает и пишет ту же самую колонку, что и вы. Ни отдельной таблицы, ни хитрого формата. Разница только в дисциплине: писать напрямую вы больше не должны. Вместо `order.state = 'complete'` вы вызываете событие, а гем сначала проверяет, разрешён ли переход, и только потом меняет значение.

Отсюда сразу два вывода, которые пригодятся:

1. `Order.where(state: 'complete')` продолжает работать как обычный SQL-запрос. Автомат ничего не ломает в запросах.
2. `order.update_column(:state, 'complete')` обойдёт автомат целиком. Это иногда нужно (миграции данных, фикстуры), но об этом отдельно в разделе про грабли.

Исключение - [`statesman`](https://github.com/gocardless/statesman): там текущее состояние вычисляется из таблицы переходов. Для остальных гемов из списка выше картина ровно как на схеме выше - одна колонка, одно значение.

## Ruby DSL: что это вообще такое

Вот минимальный автомат целиком:

```ruby
class Order < ApplicationRecord
  state_machine :state, initial: :cart do
    event :checkout do
      transition cart: :payment
    end

    event :pay do
      transition payment: :complete
    end

    event :cancel do
      transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
    end

    after_transition to: :complete, do: :send_confirmation
  end
end
```

Читается почти как техническое задание: «заказ начинается в корзине; событие `checkout` переводит его из корзины в оплату; `pay` - из оплаты в завершение; `cancel` переводит в отменённый из любой из трёх фаз, если объект отвечает, что отмена разрешена; после попадания в `complete` отправляем подтверждение».

Для человека, пришедшего из Java, C#, Go или TypeScript, здесь надо понять одну вещь, и она снимает 80% недоумения при чтении Ruby-кода:

**Это не специальный синтаксис языка и не конфигурационный файл. Это обычные вызовы методов.**

Разберём построчно, что происходит на самом деле:

| Строка в DSL | Что это на самом деле |
|---|---|
| `state_machine :state, initial: :cart do ... end` | вызов метода `state_machine` с двумя аргументами (символ и хеш `{initial: :cart}`) и блоком кода |
| `:cart` | символ (`Symbol`) - неизменяемая строка-идентификатор, аналог enum-константы; дешевле обычной строки |
| `event :checkout do ... end` | вызов метода `event`, блок исполняется в контексте объекта-строителя события |
| `transition cart: :payment` | вызов метода `transition` с хешем; ключ - исходное состояние, значение - целевое |
| `[:cart, :payment] => :canceled` | тот же хеш, но ключ - массив: «из любого из этих» |
| `if: :cancelable?` | символ вместо функции: «когда придёт время, вызови метод с таким именем на объекте» |
| `after_transition to: :complete, do: :send_confirmation` | ещё один вызов метода, регистрирующий обработчик |

Работает это благодаря двум свойствам Ruby, которых нет в большинстве привычных языков:

**Скобки при вызове метода необязательны.** `puts "hi"` и `puts("hi")` - одно и то же. Поэтому `event :checkout` выглядит как ключевое слово, а на деле это `event(:checkout)`.

**Любому методу можно передать блок кода.** Блок - это кусок кода в `do ... end` или `{ ... }`, который метод может выполнить когда захочет и в каком захочет контексте. Гем принимает ваш блок и исполняет его на своём объекте-строителе, поэтому внутри блока внезапно доступны методы `event` и `transition`, которых у модели нет.

Из этих двух свойств и вырастают DSL - «языки внутри языка». Вы их уже видели, даже если не знали названия: `has_many :comments`, `validates :email, presence: true`, `before_action :authenticate` - всё это ровно такие же обычные вызовы методов.

Практический вывод для чтения незнакомого Ruby-кода: если строка непонятна, спрашивайте не «что это за синтаксис», а «какой метод здесь вызывается и с какими аргументами». Ответ почти всегда находится в документации гема, а не в справочнике по языку.

## Что гем пишет за вас

Объявив автомат, вы получаете набор методов, которых нет в исходниках. Они создаются во время загрузки класса - это называется метапрограммированием, и в Rails оно повсюду.

Для события `pay` и состояния `complete` появятся:

| Метод | Что делает |
|---|---|
| `order.complete?` | **предикат** - проверка текущего состояния, `true` или `false` |
| `order.pay` | пытается выполнить переход; при неудаче возвращает `false` и ничего не меняет |
| `order.pay!` | то же, но при неудаче бросает исключение |
| `order.can_pay?` | можно ли выполнить событие прямо сейчас, **не выполняя его** |
| `order.state_transitions` | список доступных отсюда переходов |
| `Order.with_state(:complete)` | скоуп для запроса |

Три замечания, каждое из которых экономит час недоумения.

**Про восклицательный знак.** `pay!` и `pay` - два разных метода, `!` это часть имени, а не оператор. В Ruby есть соглашение: версия с `!` опаснее версии без него. Что именно значит «опаснее», зависит от библиотеки. Здесь - «бросит исключение вместо тихого `false`». В ActiveRecord `save!` против `save` - ровно та же пара. Выбор простой: `pay!` там, где невозможность перехода это баг и надо падать; `pay` там, где это нормальный сценарий и вы проверяете результат.

**Про grep.** Поиск по проекту `def pay!` не найдёт ничего. Метод не написан, он сгенерирован. Для новичка в Ruby это самая частая точка растерянности: метод вызывается, работает, а определения нет. Лечится привычкой: не нашли определение - ищите декларацию (`state_machine`, `has_many`, `enum`, `delegate`), которая его породила. Помогает и консоль: `order.method(:pay!).source_location` покажет, из какого файла гема метод пришёл.

**Про `can_pay?`.** Самый недооценённый метод из списка. Он отвечает на вопрос «получится ли», не делая попытки, и потому идеален для двух вещей: показать или спрятать кнопку в интерфейсе, и проверить актуальность в фоновой задаче.

```ruby
class OrderPaymentJob
  def perform(order_id)
    order = Order.find(order_id)
    # между постановкой в очередь и исполнением прошло время,
    # заказ могли отменить руками в админке
    return unless order.can_pay?

    order.pay!
  end
end
```

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

### Ловушка с похожими именами

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

```ruby
# сгенерирован автоматом: state == 'complete'
order.complete?

# написан руками где-то в модели: заполнена ли колонка с датой
def completed?
  completed_at.present?
end
```

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

## Guard-условия: где живёт слово «нельзя»

Guard (сторожевое условие) - это проверка, привязанная к переходу. Если она вернула ложь, перехода не будет.

```ruby
event :cancel do
  transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end

def cancelable?
  return false if canceled?
  shipment.nil? || shipment.pending?
end
```

Бизнес-правило «нельзя отменить заказ, который уже уехал» записано ровно один раз. Дальше оно действует везде: в контроллере, в админке, в API, в rake-задаче. Никто не может случайно обойти его, потому что обходить нечего - проверка встроена в сам переход.

Что происходит при неудаче:

```ruby
order.cancel    # => false, состояние не изменилось
order.cancel!   # => исключение StateMachines::InvalidTransition
order.reload.state # => 'complete', как и было
```

Guard можно задать двумя способами, и они взаимозаменяемы:

```ruby
transition complete: :canceled, if: :cancelable?              # символ = имя метода
transition complete: :canceled, if: ->(order) { order.paid? } # лямбда
```

Лямбда - это анонимная функция как объект: её можно положить в переменную, передать аргументом и вызвать позже. Гем сохраняет её при загрузке класса и дёргает в момент попытки перехода. Символ по сути то же самое, просто короче; используйте лямбду, когда условие одноразовое, и метод, когда его хочется переиспользовать или протестировать отдельно.

Важное различие, которое часто путают:

- **валидация** отвечает на вопрос «данные корректны?» (`validates :email, presence: true`);
- **guard** отвечает на вопрос «это действие применимо к объекту в его текущем состоянии?».

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

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

```ruby
event :cancel do
  transition to: :canceled, if: :cancelable?
end
```

Тогда перейти можно из любого состояния, а всю фильтрацию делает guard. Так пишут, когда список исходных состояний длинный и его неудобно поддерживать. Цена - правило перестаёт быть видно в декларации, надо идти читать метод.

## Реакции на переход: before и after

Callbacks - это «а что произойдёт при». Их два вида, и разница между ними принципиальная.

```ruby
state_machine :state, initial: :cart do
  before_transition to: :complete do |order|
    order.charge_payment!   # вернул false -> перехода не будет
  end

  after_transition to: :complete, do: :send_confirmation
  after_transition to: :canceled, do: :restock_items
end
```

**`before_transition`** выполняется до смены состояния и **может её отменить**: если блок вернул `false`, переход не состоится, объект останется как был. Сюда кладут то, без чего переход не имеет смысла. Списание денег - каноничный пример: нет оплаты, нет завершённого заказа.

**`after_transition`** выполняется после того, как состояние уже изменилось, и отменить переход не может. Сюда кладут последствия: письма, документы, уведомления в очередь.

```mermaid
sequenceDiagram
    participant App as order.pay!
    participant Guard as guard / before
    participant DB as колонка state
    participant After as after_transition

    App->>Guard: can_pay? и before_transition
    alt before вернул false
        Guard-->>App: переход отменён
    else всё ок
        Guard->>DB: payment -> complete
        DB->>After: finalize, invoice, enqueue...
        After-->>App: готово (внутри той же транзакции)
    end
```

Простое правило выбора: если ответ на вопрос «а если это упадёт, переход должен отмениться?» - да, значит `before`. Если нет - `after`.

### Порядок и невидимые подписчики

Callbacks выполняются в порядке регистрации. Пока их два, это не важно. Проблема появляется в живом проекте, где на один и тот же переход подписываются из разных файлов - из модели, из подключённых модулей (concerns), из декораторов сторонних гемов:

```ruby
# order.rb
after_transition to: :complete, do: :finalize!

# order/invoiceable.rb (concern)
after_transition to: :complete, do: :create_invoice

# order/loyalty.rb (concern)
after_transition to: :complete, do: :award_points
```

Формально всё нормально. Практически - **добавляя пятый callback, вы не видите четырёх остальных.** Порядок их исполнения зависит от порядка загрузки файлов, а он в Rails не всегда очевиден. Чтобы узнать, что реально произойдёт при завершении заказа, приходится делать grep по `after_transition` и складывать картину в голове.

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

## Сквозной сюжет: оплата, счёт, письмо

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

Клиент вернулся от платёжного шлюза. Контроллер делает две строки:

```ruby
payment.complete!
order.pay!
```

Дальше запускается цепочка, которую полезно один раз увидеть целиком:

```mermaid
flowchart TD
    A[Клиент вернулся от платёжного шлюза] --> B["контроллер: order.pay!"]
    B --> TX

    subgraph TX["Транзакция БД"]
      direction TB
      C["before_transition to: complete<br/>charge_payment!"]
      C -->|false| X[переход отменён]
      C -->|ok| D["UPDATE state:<br/>payment -> complete"]
      D --> E["after_transition"]
      E --> E1[finalize!]
      E --> E2[create_invoice]
      E --> E3[reserve_stock]
      E --> E4["enqueue email ← ОПАСНОЕ МЕСТО"]
    end

    TX --> F[COMMIT]
    F --> G[Sidekiq: другой процесс, другое соединение]
    G --> H[ConfirmationEmailJob]
    H --> I["Order.find(id) - увидит ли он заказ?"]
    I --> J[письмо у клиента]
```

Ключевой момент диаграммы - рамка транзакции. Переход состояния и **все** его callbacks выполняются внутри одной транзакции БД. Пока не случился `COMMIT`, изменений для внешнего мира не существует.

А Sidekiq - это внешний мир. Отдельный процесс, отдельное соединение с базой, отдельная очередь в Redis. Из этого следует главная ловушка всей темы, ей посвящён следующий раздел.

## Что не стоит копировать бездумно

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

### 1. Побочные эффекты внутри транзакции перехода

Это ошибка номер один, и она коварна тем, что на машине разработчика не воспроизводится.

```ruby
after_transition to: :complete do |order|
  ConfirmationEmailJob.perform_async(order.id)   # плохо
end
```

Что происходит: `perform_async` кладёт задачу в Redis **мгновенно**. Транзакция БД ещё открыта. Sidekiq-воркер в другом процессе просыпается через миллисекунды, делает `Order.find(id)` со своим соединением и не видит незакоммиченных изменений. В лучшем случае он прочитает старое состояние и отправит письмо «ваш заказ оплачен» про заказ, который в базе ещё в статусе `payment`. В худшем - если запись создавалась в этой же транзакции - получит `ActiveRecord::RecordNotFound`.

```mermaid
sequenceDiagram
    participant C as Controller
    participant DB as Транзакция БД
    participant R as Redis
    participant W as Sidekiq worker

    C->>DB: order.pay!
    Note over DB: state уже complete в этой транзакции
    DB->>R: perform_async(order.id)
    R->>W: job стартует слишком рано
    W->>DB: Order.find(id)
    Note over W,DB: worker не видит uncommitted данные
    DB-->>C: COMMIT
```

Локально этого не видно: на пустой базе транзакция коммитится за микросекунды, а часто Sidekiq в разработке вообще работает синхронно. Ошибка вылезает в production под нагрузкой, плавает и выглядит необъяснимой.

Первое, что придумывают, столкнувшись с проблемой, - задержка:

```ruby
after_transition to: :complete do |order|
  ConfirmationEmailJob.perform_at(30.seconds.from_now, order.id)  # тоже плохо
end
```

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

Правильных решений два.

**`after_commit` - хук ActiveRecord, срабатывающий гарантированно после успешного коммита.** В callback перехода ставим только флаг в памяти объекта, а в очередь отправляем уже снаружи транзакции:

```ruby
class Order < ApplicationRecord
  state_machine :state, initial: :cart do
    after_transition to: :complete do |order|
      # не ставим задачу здесь: транзакция ещё не закоммичена
      order.instance_variable_set(:@send_confirmation_after_commit, true)
    end
  end

  after_commit :enqueue_confirmation

  private

  def enqueue_confirmation
    return unless @send_confirmation_after_commit

    @send_confirmation_after_commit = false
    ConfirmationEmailJob.perform_async(id)
  end
end
```

Выглядит многословнее, чем одна строка с `perform_async`, зато работает детерминированно.

**Транзакционная очередь.** Если очередь хранится в той же базе, что и данные (`solid_queue` в Rails 8, `good_job`, `delayed_job`), задача записывается той же транзакцией и проблема исчезает по построению: не закоммитилось - значит и задачи нет. Это одна из главных причин, по которым проекты уходят с Sidekiq на очередь в Postgres.

**Правило: из callback перехода задача в Redis-очередь ставится только через `after_commit`.**

У той же транзакции есть вторая грань: исключение внутри `after_transition` откатит и сам переход.

```ruby
after_transition to: :complete, do: :generate_pdf_invoice   # рискованно
```

Упала генерация PDF - заказ не завершился. Клиент заплатил, а в базе состояние `payment`. Сбой второстепенной функции уронил основную. Вывод тот же самый: чем дальше побочный эффект от транзакции, тем меньше он может ей навредить. Вынесенная через `after_commit` фоновая задача решает обе проблемы разом.

Два смежных правила про сами задачи, раз уж мы здесь:

- **Передавайте `id`, а не объект.** Во-первых, в очередь сериализуется только JSON (числа, строки, массивы, хеши). Во-вторых, объект, пролежавший минуту в очереди, всё равно устарел - воркер должен читать свежие данные.
- **Задача должна быть готова выполниться дважды.** Очереди гарантируют «хотя бы один раз», а не «ровно один раз», и вдобавок Sidekiq по умолчанию повторяет упавшую задачу до 25 раз. Простейшая защита от дубля письма - колонка с датой отправки и проверка перед отправкой.

### 2. Прямая запись в колонку состояния

```ruby
order.update_column(:state, 'complete')   # мимо автомата
```

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

Но два следствия надо держать в голове.

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

**Проверка допустимости не сработает тоже.** Так можно получить объект в комбинации, которую процесс не предусматривал, и обнаружить это через полгода в отчёте.

Практическое правило: прямая запись допустима там, где вы **готовите** данные, и недопустима там, где вы **обрабатываете** бизнес-событие.

### 3. Автомат как замена сервисному слою

Последнее - не про конкретный код, а про меру. Автомат хорош, пока описывает жизненный цикл. Он становится плох, когда в него въезжает весь бизнес-процесс.

Симптомы, что пора остановиться: у одного перехода десяток подписчиков; порядок callbacks приходится задавать вручную; в guard одного callback появляется проверка на результат другого; чтобы понять, что делает `order.pay!`, надо открыть шесть файлов.

Лечение обычно одно: оставить в автомате смену состояния и то, что от неё неотделимо, а остальное собрать в явный сервисный объект, где последовательность шагов видно сверху вниз:

```ruby
class CompleteOrder
  def call(order)
    order.pay!              # автомат отвечает только за состояние
    InvoiceCreator.new.call(order)
    StockReserver.new.call(order)
    ConfirmationEmailJob.perform_async(order.id)
  end
end
```

Читается за десять секунд, отлаживается пошагово, тестируется по частям. Скучно - и в этом достоинство.

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

Тесты на автомат делятся на три группы, и все три проверяют **наблюдаемое поведение**, а не внутреннее устройство.

**Первое: переход разрешён из правильных состояний.** Самый дешёвый тест, побочных эффектов нет вообще:

```ruby
RSpec.describe Order do
  it 'allows cancelling a fresh order' do
    order = create(:order, state: 'payment')
    expect(order.can_cancel?).to be true
  end
end
```

**Второе: guard действительно блокирует.** Проверяем и возвращаемое значение, и то, что состояние не поехало:

```ruby
it 'refuses to cancel a shipped order' do
  order = create(:order, :shipped)

  expect(order.cancel).to be false
  expect(order.reload.state).to eq('complete')
end
```

`reload` здесь не формальность: он перечитывает объект из базы и доказывает, что в базе тоже ничего не изменилось, а не только в памяти.

**Третье: побочный эффект перехода произошёл.** Главный тест сюжета этой статьи:

```ruby
RSpec.describe Order do
  describe 'transition to complete' do
    let(:order) { create(:order, :ready_for_payment) }

    it 'creates an invoice' do
      expect { order.pay! }.to change { order.invoices.count }.by(1)
    end

    it 'enqueues the confirmation email' do
      expect { order.pay! }
        .to change(ConfirmationEmailJob.jobs, :size).by(1)
    end
  end
end
```

Три замечания к этому набору.

**Про фабрику.** `:ready_for_payment` должна собрать заказ, который реально может перейти в `complete`: с позициями, адресом, доставкой и платежом. Соблазн сократить до `create(:order, state: 'payment')` велик, но тогда guard упрётся в отсутствующие данные, и тест будет проверять не то, что вы думаете. Заказ, состояние которому проставили напрямую, для теста перехода бесполезен: перехода не было.

**Про `change`.** Матчер `expect { ... }.to change { ... }.by(1)` измеряет значение до и после блока. Он проверяет результат, а не способ его достижения, поэтому переживёт рефакторинг внутренностей.

**Чего делать не надо.** Не проверяйте, что был вызван внутренний метод, и не мокайте воркер:

```ruby
# не надо: тест привязан к текущей реализации
expect(order).to receive(:create_invoice)
expect(InvoiceJob).to receive(:perform_async)
```

Через год цепочка изменится - счёт будет создавать другой класс, задача поедет через `after_commit` - и такой тест сломается, хотя поведение осталось правильным. Проверяйте то, что видит внешний мир: строку в базе, размер очереди, тело ответа.

## Глоссарий

| Термин | Значение |
|---|---|
| **State machine (конечный автомат)** | Модель процесса: конечный набор состояний плюс правила перехода между ними. В Rails реализуется гемами `state_machines-activerecord` или `aasm`. |
| **State (состояние)** | Именованная фаза жизни объекта: `cart`, `complete`, `canceled`. Обычно хранится строкой в колонке БД. |
| **Event (событие)** | Именованное действие, запускающее переход: `pay`, `cancel`. Порождает методы `pay`, `pay!`, `can_pay?`. |
| **Transition (переход)** | Правило «из состояний A, B можно попасть в C», объявляется внутри события. Не описанный правилом переход невозможен. |
| **Guard (сторожевое условие)** | Условие `if:` / `unless:` на переходе. Ложно - перехода не будет. Отвечает на вопрос «применимо ли действие», в отличие от валидации, которая проверяет корректность данных. |
| **Predicate (предикат)** | Метод-вопрос, возвращающий `true`/`false`: `complete?`. Сгенерированные автоматом предикаты не найти поиском по `def`. |
| **Bang-метод (`pay!`)** | Соглашение Ruby: версия метода, которая при неудаче бросает исключение вместо возврата `false`. Восклицательный знак - часть имени, а не оператор. |
| **Callback** | Код, привязанный к моменту перехода. `before_transition` выполняется до смены состояния и может её отменить; `after_transition` - после, отменить не может. |
| **DSL (domain-specific language)** | «Язык внутри языка». В Ruby - обычные вызовы методов с блоками, которые за счёт необязательных скобок читаются как декларативное описание. |
| **Symbol (`:complete`)** | Неизменяемый идентификатор-строка. Дешевле обычной строки, используется как имя состояния, события или метода. |
| **Lambda / Proc** | Анонимная функция как объект: можно сохранить в переменную, передать аргументом и вызвать позже. На этом держатся guard-условия. |
| **Метапрограммирование** | Создание методов во время выполнения. Причина, по которой `pay!` работает, но его определения нет в коде. |
| **Worker / Job (Sidekiq)** | Класс с методом `perform`, исполняемый в отдельном процессе через очередь в Redis. Принимает только JSON-совместимые аргументы, поэтому передают `id`, а не объект. |
| **`after_commit`** | Хук ActiveRecord, срабатывающий после успешного коммита транзакции. Единственное безопасное место для постановки задачи в Redis-очередь. |

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

- [`state_machines`](https://github.com/state-machines/state_machines) и [`aasm`](https://github.com/aasm/aasm) - синтаксис отличается, идеи одинаковые; полезно пролистать оба README подряд, чтобы отделить концепцию от конкретного API.
- [`statesman`](https://github.com/gocardless/statesman) - посмотрите, даже если не собираетесь использовать: хранение переходов отдельной таблицей меняет взгляд на задачу. Состояние перестаёт быть значением и становится следствием истории.
- [ActiveRecord::Enum](https://api.rubyonrails.org/classes/ActiveRecord/Enum.html) - если после статьи кажется, что автомат вам не нужен, это честный и правильный вывод; начните отсюда.
- [`after_commit` и остальные callbacks ActiveRecord](https://guides.rubyonrails.org/active_record_callbacks.html) - раздел про транзакционные хуки стоит прочитать целиком.
- Рекомендуемое продолжение темы: у одного объекта автоматов обычно несколько (`Order`, `Payment`, `Shipment`), и они не синхронизированы между собой. «Заказ завершён» не значит «деньги получены», а «деньги получены» не значит «посылка уехала». Именно на стыке этих автоматов живут самые дорогие баги.
