mybook

State パターン — 状態遷移を管理する

「ステータスによって処理が変わりすぎる」

入社5ヶ月目のケンタは、注文管理機能の改修を担当していた。

「山田さん、注文のcancel!メソッドを見てください。ちょっとひどくて……」

ケンタがエディタを開くと、そこには恐ろしいコードが広がっていた。

class Order < ApplicationRecord
  def can_cancel?
    status == "pending" || status == "paid"
  end
 
  def cancel!
    if status == "pending"
      update!(status: "cancelled")
    elsif status == "paid"
      refund!
      update!(status: "cancelled")
    elsif status == "shipped"
      raise "発送済みの注文はキャンセルできません"
    elsif status == "delivered"
      raise "配達済みの注文はキャンセルできません"
    elsif status == "cancelled"
      raise "すでにキャンセル済みです"
    end
  end
 
  def ship!
    if status == "paid"
      update!(status: "shipped", shipped_at: Time.current)
      notify_customer
    else
      raise "支払い済みの注文のみ発送できます(現在: #{status})"
    end
  end
 
  def pay!
    if status == "pending"
      update!(status: "paid", paid_at: Time.current)
      grant_points
    elsif status == "paid"
      raise "すでに支払い済みです"
    else
      raise "この状態では支払いできません(現在: #{status})"
    end
  end
end

山田さんは黙って画面を見つめた。

「これ、ステータスが1つ増えるたびに全メソッドを修正しないといけないですよね」とケンタは言った。「先週『返金処理中』ステータスを追加したとき、cancel!ship!pay!can_cancel?……全部を修正しました。」

「修正漏れが起きそうだね」と山田さんは静かに言った。「実際、deliver!の中にrefunded状態のチェックが抜けていた。先週のバグの原因はそれだよ。」

ケンタは身に覚えがあった。深夜に修正した記憶がよみがえる。

「これはStateパターンで解決できる。状態ごとの振る舞いを、状態クラスに分散させるんだ。」

State パターンとは

State パターンは、オブジェクトの内部状態が変わると振る舞いが変わるようにするパターンだ。状態を独立したオブジェクトとして表現し、状態ごとのロジックをそれぞれのクラスに閉じ込める。

日常の比喩は信号機だ。「赤」「黄」「青」という状態があり、それぞれの状態で「どの操作が許可されているか」が異なる。赤信号のときに「青いから進める」という判断はできない。状態が振る舞いを決定する。

もう一つの比喩は自動販売機だ。お金を入れる前(待機状態)、お金が入った後(選択可能状態)、商品を取り出す途中(払い出し状態)で、同じボタンを押しても動作が変わる。状態が変われば、同じ操作への反応も変わる。

Loading diagram...

INFO

状態遷移図を最初に書くことで、どの遷移が許可されていてどれが禁止されているかが一目でわかる。コードを書く前にこの図を描く習慣をつけると、設計ミスを事前に防げる。

AASM gem — Railsの状態機械

Railsでは AASM(Acts As State Machine) というgemが定番だ。

# Gemfile
gem "aasm"
bundle install
rails generate migration AddStatusToOrders status:string
rails db:migrate
# app/models/order.rb
class Order < ApplicationRecord
  include AASM
 
  aasm column: :status, whiny_transitions: false do
    # 状態の定義
    state :pending, initial: true
    state :paid
    state :shipped
    state :delivered
    state :cancelled
    state :refunded
 
    # 遷移の定義(event = トリガー)
    event :pay do
      transitions from: :pending, to: :paid
      before { validate_payment_method! }
      after do
        update!(paid_at: Time.current)
        OrderMailer.payment_confirmation(self).deliver_later
        grant_purchase_points
      end
    end
 
    event :ship do
      transitions from: :paid, to: :shipped,
                  guard: :inventory_available?
      after do
        update!(shipped_at: Time.current)
        OrderMailer.shipping_notification(self).deliver_later
        TrackingService.create_shipment(self)
      end
    end
 
    event :deliver do
      transitions from: :shipped, to: :delivered
      after do
        update!(delivered_at: Time.current)
        OrderMailer.delivery_confirmation(self).deliver_later
      end
    end
 
    event :cancel do
      transitions from: :pending, to: :cancelled
      transitions from: :paid, to: :cancelled, after: :process_refund
      transitions from: :shipped, to: :cancelled, guard: :can_cancel_shipped?
    end
 
    event :refund do
      transitions from: [:paid, :delivered], to: :refunded
      after { process_refund }
    end
  end
 
  private
 
  def validate_payment_method!
    raise InvalidPaymentError, "有効な決済方法が登録されていません" unless user.has_valid_payment_method?
  end
 
  def inventory_available?
    order_items.all? { |item| item.product.stock >= item.quantity }
  end
 
  def can_cancel_shipped?
    shipped_at > 24.hours.ago
  end
 
  def process_refund
    RefundService.new(order: self, amount: total_price).execute
  end
 
  def grant_purchase_points
    point_amount = (total_price / 100).floor
    user.increment!(:points, point_amount)
    PointGrantLog.create!(user: user, order: self, points: point_amount)
  end
end

INFO

whiny_transitions: false を設定すると、不正な遷移でも例外を出さずに false を返す。may_[event]? で事前に確認してから実行するスタイルになる。チームのエラーハンドリング戦略に合わせて選択しよう。

AASMが自動生成するメソッド

AASMを使うと、定義した状態とイベントに対して多くのメソッドが自動的に生成される。

order = Order.create!(user: current_user)
 
# 状態確認メソッド(状態名? の形式)
order.status      # => "pending"
order.pending?    # => true
order.paid?       # => false
order.shipped?    # => false
 
# 遷移可能確認メソッド(may_イベント名? の形式)
order.may_pay?    # => true(pendingからpayは可能)
order.may_ship?   # => false(pendingからshipは不可)
 
# 遷移実行メソッド(イベント名! の形式 — 失敗すると例外)
order.pay!        # pending → paid に遷移
order.status      # => "paid"
 
# 遷移実行(失敗しても例外を出さない版)
order.ship        # trueかfalseを返す
order.may_ship?   # => true(paidからshipは可能)
 
order.ship!       # paid → shipped に遷移
order.cancel!     # shipped → cancelled(guard条件次第)
 
# 不正な遷移を試みる
order.pay!        # => AASM::InvalidTransition(paid状態からpayはできない)
order.may_pay?    # => false

コントローラでの実装

コントローラはシンプルに保つ。状態遷移のロジックはモデルに任せる。

# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :authenticate_user!
  before_action :set_order
 
  def pay
    unless @order.may_pay?
      return render json: {
        error: "現在の状態では支払いできません",
        current_status: @order.status,
        allowed_from: ["pending"]
      }, status: :unprocessable_entity
    end
 
    if @order.pay
      render json: {
        message: "支払いが完了しました",
        order: OrderSerializer.new(@order).as_json
      }
    else
      render json: {
        error: "支払い処理に失敗しました",
        details: @order.errors.full_messages
      }, status: :unprocessable_entity
    end
  end
 
  def ship
    unless @order.may_ship?
      return render json: {
        error: "この注文は発送できません",
        current_status: @order.status
      }, status: :unprocessable_entity
    end
 
    if @order.ship
      render json: { message: "発送処理が完了しました", order: OrderSerializer.new(@order).as_json }
    else
      render json: { error: "在庫が不足しています" }, status: :unprocessable_entity
    end
  end
 
  def cancel
    unless @order.may_cancel?
      return render json: {
        error: "この注文はキャンセルできません",
        current_status: @order.status
      }, status: :unprocessable_entity
    end
 
    if @order.cancel
      render json: { message: "注文をキャンセルしました" }
    else
      render json: { error: "キャンセル処理に失敗しました" }, status: :unprocessable_entity
    end
  end
 
  private
 
  def set_order
    @order = current_user.orders.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    render json: { error: "注文が見つかりません" }, status: :not_found
  end
end

カスタムState実装(AASMなし)

AASMを使わない純粋なRubyでのState実装も理解しておこう。Stateパターンの本質が見えてくる。

# app/states/order_states/base_state.rb
module OrderStates
  class BaseState
    def pay!(order)
      raise InvalidTransitionError, "#{order.status}状態からpayはできません"
    end
 
    def ship!(order)
      raise InvalidTransitionError, "#{order.status}状態からshipはできません"
    end
 
    def deliver!(order)
      raise InvalidTransitionError, "#{order.status}状態からdeliverはできません"
    end
 
    def cancel!(order)
      raise InvalidTransitionError, "#{order.status}状態からcancelはできません"
    end
 
    def can_cancel?
      false
    end
  end
end
# app/states/order_states/pending_state.rb
module OrderStates
  class PendingState < BaseState
    def pay!(order)
      order.update!(status: "paid", paid_at: Time.current)
      order.state = PaidState.new
      OrderMailer.payment_confirmation(order).deliver_later
    end
 
    def cancel!(order)
      order.update!(status: "cancelled", cancelled_at: Time.current)
      order.state = CancelledState.new
    end
 
    def can_cancel? = true
 
    def to_s = "pending"
    def label = "仮注文"
  end
end
# app/states/order_states/paid_state.rb
module OrderStates
  class PaidState < BaseState
    def ship!(order)
      raise "在庫が不足しています" unless order.inventory_available?
 
      order.update!(status: "shipped", shipped_at: Time.current)
      order.state = ShippedState.new
      OrderMailer.shipping_notification(order).deliver_later
    end
 
    def cancel!(order)
      order.process_refund
      order.update!(status: "cancelled", cancelled_at: Time.current)
      order.state = CancelledState.new
    end
 
    def can_cancel? = true
    def to_s = "paid"
    def label = "支払済"
  end
end
# app/states/order_states/shipped_state.rb
module OrderStates
  class ShippedState < BaseState
    def deliver!(order)
      order.update!(status: "delivered", delivered_at: Time.current)
      order.state = DeliveredState.new
    end
 
    def cancel!(order)
      if order.shipped_at > 24.hours.ago
        order.process_refund
        order.update!(status: "cancelled")
        order.state = CancelledState.new
      else
        raise InvalidTransitionError, "発送から24時間以上経過したため、キャンセルできません"
      end
    end
 
    def can_cancel? = order_shipped_recently?
    def to_s = "shipped"
    def label = "発送済"
 
    private
 
    def order_shipped_recently?
      # 個別のorderインスタンスを持たないため、Orderから呼ばれる際にorderを渡す設計が必要
      true
    end
  end
end
# app/models/order.rb(状態クラスを使う場合)
class Order < ApplicationRecord
  attr_writer :state
 
  def state
    @state ||= case status
               when "pending"   then OrderStates::PendingState.new
               when "paid"      then OrderStates::PaidState.new
               when "shipped"   then OrderStates::ShippedState.new
               when "delivered" then OrderStates::DeliveredState.new
               when "cancelled" then OrderStates::CancelledState.new
               when "refunded"  then OrderStates::RefundedState.new
               else raise "未知のステータス: #{status}"
               end
  end
 
  # 状態クラスに委譲
  def pay!    = state.pay!(self)
  def ship!   = state.ship!(self)
  def deliver! = state.deliver!(self)
  def cancel! = state.cancel!(self)
  def can_cancel? = state.can_cancel?
  def status_label = state.label
end

WARNING

純粋なRuby実装はStateパターンの本質を理解するには良いが、プロダクションコードではAASMのような実績のあるgemを使った方が、バグが少なく保守しやすい。ただし、状態数が3以下のシンプルなケースではAASMを使わず、enumで十分なことも多い。

状態遷移ログの記録

状態がいつ、誰によって変更されたかを記録することは、監査やデバッグに欠かせない。

# db/migrate/xxxxx_create_order_transition_logs.rb
class CreateOrderTransitionLogs < ActiveRecord::Migration[7.1]
  def change
    create_table :order_transition_logs do |t|
      t.references :order, null: false, foreign_key: true
      t.references :user, null: true, foreign_key: true
      t.string :from_state, null: false
      t.string :to_state, null: false
      t.string :event, null: false
      t.jsonb :metadata, default: {}
      t.string :ip_address
      t.timestamps
    end
 
    add_index :order_transition_logs, [:order_id, :created_at]
  end
end
# app/models/order_transition_log.rb
class OrderTransitionLog < ApplicationRecord
  belongs_to :order
  belongs_to :user, optional: true
 
  scope :recent, -> { order(created_at: :desc) }
  scope :for_event, ->(event) { where(event: event) }
 
  def self.last_transition_for(order)
    where(order: order).order(created_at: :desc).first
  end
end
# app/models/order.rb(ログ記録を追加)
class Order < ApplicationRecord
  include AASM
 
  aasm column: :status, whiny_transitions: false do
    # ...(前述の定義)
    after_all_transitions :log_transition
  end
 
  private
 
  def log_transition
    OrderTransitionLog.create!(
      order: self,
      from_state: aasm.from_state.to_s,
      to_state: aasm.to_state.to_s,
      event: aasm.current_event.to_s,
      user: Current.user,
      ip_address: Current.ip_address,
      metadata: {
        user_agent: Current.user_agent,
        total_price: total_price
      }
    )
  end
end
# 管理画面での活用例
# app/controllers/admin/orders_controller.rb
class Admin::OrdersController < Admin::BaseController
  def history
    @order = Order.find(params[:id])
    @transitions = @order.order_transition_logs
                         .includes(:user)
                         .recent
                         .limit(20)
  end
end

テスト

AASMを使ったモデルのテストは、状態遷移の仕様を明確にドキュメント化する役割も持つ。

# spec/models/order_spec.rb
RSpec.describe Order, type: :model do
  describe "状態遷移" do
    subject(:order) { create(:order) }
 
    describe "初期状態" do
      it "pendingで始まる" do
        expect(order).to be_pending
        expect(order.status).to eq("pending")
      end
    end
 
    describe "pay! イベント" do
      it "pending → paid に遷移する" do
        expect { order.pay! }.to change(order, :status).from("pending").to("paid")
      end
 
      it "paid_at が設定される" do
        freeze_time do
          order.pay!
          expect(order.reload.paid_at).to eq(Time.current)
        end
      end
 
      it "確認メールをエンキューする" do
        expect { order.pay! }.to have_enqueued_mail(OrderMailer, :payment_confirmation)
      end
 
      it "ポイントが付与される" do
        order.update!(total_price: 10_000)
        expect { order.pay! }.to change { order.user.reload.points }.by(100)
      end
 
      context "paid状態からは遷移できない" do
        before { order.pay! }
 
        it "may_pay? がfalseになる" do
          expect(order.may_pay?).to be false
        end
      end
    end
 
    describe "ship! イベント" do
      before { order.pay! }
 
      it "paid → shipped に遷移する" do
        expect { order.ship! }.to change(order, :status).from("paid").to("shipped")
      end
 
      it "shipped_at が設定される" do
        freeze_time do
          order.ship!
          expect(order.reload.shipped_at).to eq(Time.current)
        end
      end
 
      context "在庫が不足している場合" do
        before do
          order.order_items.each do |item|
            item.product.update!(stock: 0)
          end
        end
 
        it "遷移できない" do
          expect(order.may_ship?).to be false
        end
      end
    end
 
    describe "cancel! イベント" do
      context "pending状態のキャンセル" do
        it "返金なしでキャンセルされる" do
          expect(RefundService).not_to receive(:new)
          order.cancel!
          expect(order).to be_cancelled
        end
      end
 
      context "paid状態のキャンセル" do
        before { order.pay! }
 
        it "返金処理が実行される" do
          expect(RefundService).to receive(:new)
            .with(order: order, amount: order.total_price)
            .and_return(double(execute: true))
          order.cancel!
        end
      end
 
      context "delivered状態はキャンセルできない" do
        before do
          order.pay!
          order.ship!
          order.deliver!
        end
 
        it "may_cancel? がfalseになる" do
          expect(order.may_cancel?).to be false
        end
      end
    end
 
    describe "遷移ログ" do
      it "遷移時にログが作成される" do
        expect { order.pay! }.to change(OrderTransitionLog, :count).by(1)
      end
 
      it "ログに正しい遷移情報が記録される" do
        order.pay!
        log = OrderTransitionLog.last
        expect(log.from_state).to eq("pending")
        expect(log.to_state).to eq("paid")
        expect(log.event).to eq("pay")
      end
    end
  end
end

AWSでのState的発想

AWSのサービスも状態機械に基づいて設計されている。インフラの状態管理とアプリケーションの状態管理は、同じパターンで考えられる。

Loading diagram...

EC2インスタンスのライフサイクルは状態機械の教科書例だ。pending → running → stopping → stopped → terminatedという遷移があり、terminated から戻ることはできない(不可逆遷移)。これはDBに保存されたdelivered状態からpendingには戻れないのと同じ発想だ。

AWS Step Functions は状態機械を視覚的に定義できるマネージドサービスだ。複雑なビジネスプロセスを状態遷移として定義できる。

{
  "Comment": "注文処理ワークフロー",
  "StartAt": "CheckInventory",
  "States": {
    "CheckInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:123456789:function:check-inventory",
      "Next": "ProcessPayment",
      "Catch": [{
        "ErrorEquals": ["InsufficientStockError"],
        "Next": "NotifyOutOfStock"
      }]
    },
    "ProcessPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:123456789:function:process-payment",
      "Next": "ShipOrder",
      "Catch": [{
        "ErrorEquals": ["PaymentFailedError"],
        "Next": "CancelOrder"
      }]
    },
    "ShipOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:123456789:function:ship-order",
      "Next": "NotifyShipped"
    },
    "NotifyShipped": {
      "Type": "Task",
      "Resource": "arn:aws:sns:...",
      "End": true
    },
    "NotifyOutOfStock": {
      "Type": "Task",
      "Resource": "arn:aws:sns:...",
      "End": true
    },
    "CancelOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:...",
      "End": true
    }
  }
}

Step Functionsでは各Stateで「成功すれば次のState、失敗すれば別のState」という遷移を定義できる。これはAASMのtransitions from: :paid, to: :shipped, guard: :inventory_available?と同じ発想だ。

RDSのスナップショット処理も状態機械として動作する。スナップショットの状態はcreating → available → deleting → deletedと遷移し、available状態でないと復元操作は実行できない。

ケンタの気づき

コードレビューのコメントが山田さんから届いた。

「AASM導入、完璧だ。can_cancel?のロジックが消えて、transitionsの定義だけで仕様が表現されている。先週追加したrefunded状態のチェックも、それぞれのイベントに自動的に反映されている。」

ケンタは達成感を感じながら、自分のノートを開いた。

「Stateパターンって、if-else地獄を状態ごとのクラスに分散させる発想ですね。そしてAASMがそれを宣言的に書けるようにしてくれる。」

「まさに」と山田さんは頷いた。「無効な遷移を不可能にするのがStateパターンの重要な効果だ。cancelledからshipできないのを、コードのチェックではなく、遷移の定義で保証できる。新しい状態を追加したとき、他の遷移に影響しない。」

「AASMのtransitions from: :paid, to: :shippedという定義が、そのまま仕様書になりますね。コードと仕様が一致している。」

「そうだ。コードが仕様を表す。それが良い設計の特徴だ。」

ケンタはノートにメモした。

Stateパターン = 状態ごとの振る舞いをクラスに分離。無効な遷移を設計レベルで防ぐ。RailsではAASM gemが宣言的な状態管理を提供する。状態遷移図を先に書くと設計がクリアになる。


INFO

この章のまとめ

  • Stateパターンはオブジェクトの状態に応じて振る舞いを変える
  • 状態ごとのif-else分岐を、状態クラスに分散させることで修正が局所化される
  • RailsではAASM gemが宣言的な状態機械を提供する
  • afterbeforeguardで遷移時のロジックを宣言できる
  • after_all_transitionsで遷移ログを記録し、監査と可視性を確保する
  • AWS Step FunctionsとEC2ライフサイクルも状態機械の実例
  • 状態遷移図を先に描くと仕様漏れを防げる