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 パターンは、オブジェクトの内部状態が変わると振る舞いが変わるようにするパターンだ。状態を独立したオブジェクトとして表現し、状態ごとのロジックをそれぞれのクラスに閉じ込める。
日常の比喩は信号機だ。「赤」「黄」「青」という状態があり、それぞれの状態で「どの操作が許可されているか」が異なる。赤信号のときに「青いから進める」という判断はできない。状態が振る舞いを決定する。
もう一つの比喩は自動販売機だ。お金を入れる前(待機状態)、お金が入った後(選択可能状態)、商品を取り出す途中(払い出し状態)で、同じボタンを押しても動作が変わる。状態が変われば、同じ操作への反応も変わる。
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
endINFO
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
endWARNING
純粋な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
endAWSでのState的発想
AWSのサービスも状態機械に基づいて設計されている。インフラの状態管理とアプリケーションの状態管理は、同じパターンで考えられる。
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が宣言的な状態機械を提供する
after、before、guardで遷移時のロジックを宣言できるafter_all_transitionsで遷移ログを記録し、監査と可視性を確保する- AWS Step FunctionsとEC2ライフサイクルも状態機械の実例
- 状態遷移図を先に描くと仕様漏れを防げる