Command パターン — 操作をオブジェクト化する
「途中でエラーになったら全部消えた」
ある月曜日の朝、ケンタは顔色が悪かった。
「どうした?」と山田さんが声をかけた。
「週末の本番リリースで……注文キャンセル処理に致命的なバグがありました。」
ケンタが説明した経緯はこうだ。注文キャンセル時に「払い戻し→在庫戻し→ポイント剥奪」の3つの処理を順番に実行していた。ところが先週末、払い戻しだけが成功して、在庫を戻す途中でDBコネクションが切れた。
結果、ユーザーにはお金が返ったのに、在庫は増えていない。ポイントも剥奪されていない。3つの処理が中途半端な状態のまま止まった。
「復旧作業が大変で……CSチームへの連絡、手動でのデータ修正、ユーザーへの謝罪メール……」
「なぜトランザクションで囲まなかったんだ?」
「囲んでました。でも払い戻しはStripeのAPIを呼ぶんです。DBのロールバックでStripeに飛んだリクエストは取り消せない。」
山田さんはうなずいた。「そうだ。それがシステム設計の難しさだよ。外部APIはROLLBACKでは戻せない。だから『Commandパターン』が必要だ。」
WARNING
DBトランザクションで解決できるのはデータベース内の操作だけ。外部API(Stripe、SendGrid、AWS S3など)を呼んだ後のロールバックはDBには不可能。これが「補償トランザクション」が必要な理由。
なぜトランザクションだけでは不十分か
「ちょっと整理してみよう」と山田さんは言った。
# Bad: DB トランザクションだけで囲む(外部APIは戻せない)
ActiveRecord::Base.transaction do
# 1. Stripe に返金リクエスト(外部API → ロールバック不可)
StripeGateway.new.refund(order.payment_id, order.total_price)
# 2. 在庫を戻す(DB操作 → ロールバック可能)
order.order_items.each { |item| item.product.increment!(:stock, item.quantity) }
# ↑ ここでエラー発生 → DB はロールバックされる
# でも Stripe はすでに返金済み!
raise ActiveRecord::Rollback
end「問題が見えますか?」
ケンタは画面を見つめた。「Stripeの返金は1行目で終わってる。その後DBがロールバックされても、Stripeには何も伝わらない。」
「正解。これが外部副作用の問題だ。DBの外に出た操作は、DBのルールで取り消せない。」
比喩を使うとこうだ。手紙を投函した後で「やっぱり取り消したい」と言っても郵便局は戻してくれない。メール送信、API呼び出し、物理的な在庫移動——これらはすべて「投函後の手紙」と同じだ。
「じゃあどうすれば?」
「各操作に『取り消し方法』を一緒に持たせる。それがCommandパターンだ。」
Command パターンとは
Command パターンは、リクエスト(操作)をオブジェクトとしてカプセル化するパターンだ。
日常の比喩はレストランの注文票だ。お客さんが「ハンバーグ定食を頼む」という操作は、注文票(Commandオブジェクト)に記録される。厨房(Receiver)は注文票を受け取って実行する。注文票があれば「キャンセル」も「変更」も管理できる。
別の比喩で言えば、**テキストエディタのCtrl+Z(アンドゥ)**だ。どうして「さっきの操作」を取り消せるのか?それは各操作が「やり方」と「元に戻し方」を一緒に持つCommandオブジェクトとして記録されているからだ。
Commandパターンで何が嬉しいか:
- 操作を キュー に入れて後で実行できる(非同期処理)
- 操作の 履歴を記録 できる(undo/redo)
- 操作を 組み合わせ られる(マクロコマンド)
- 失敗時に 逆順で取り消し できる(補償トランザクション)
INFO
Commandパターンの本質は「操作を一級市民に昇格させる」こと。変数に代入し、リストに積み、後で実行し、取り消すことができる。「動詞をオブジェクトにする」パターンとも言われる。
基本実装
# app/commands/base_command.rb
class BaseCommand
def execute
raise NotImplementedError, "#{self.class}#execute を実装してください"
end
def undo
raise NotImplementedError, "#{self.class}#undo を実装してください"
end
# Command が成功したかを外部から確認できるようにする
def executed?
@executed ||= false
end
end# app/commands/refund_command.rb
class RefundCommand < BaseCommand
def initialize(order:, amount:)
@order = order
@amount = amount
@refund_id = nil
end
def execute
result = StripeGateway.new.refund(
transaction_id: @order.payment_id,
amount: @amount
)
@refund_id = result[:refund_id]
@order.update!(refund_id: @refund_id, refund_amount: @amount)
@executed = true
end
def undo
# 補償トランザクション: 返金のキャンセルAPIを呼ぶ
return unless @executed && @refund_id
StripeGateway.new.cancel_refund(refund_id: @refund_id)
@order.update!(refund_id: nil, refund_amount: 0)
Rails.logger.info("RefundCommand.undo: refund #{@refund_id} cancelled")
@executed = false
end
end# app/commands/restock_command.rb
class RestockCommand < BaseCommand
def initialize(order:)
@order = order
end
def execute
@order.order_items.each do |item|
item.product.increment!(:stock, item.quantity)
end
@executed = true
end
def undo
return unless @executed
@order.order_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
@executed = false
end
end# app/commands/revoke_points_command.rb
class RevokePointsCommand < BaseCommand
def initialize(order:)
@order = order
@points = (order.total_price / 100).floor
end
def execute
@order.user.decrement!(:points, @points)
@executed = true
end
def undo
return unless @executed
@order.user.increment!(:points, @points)
@executed = false
end
end補償トランザクション — undo できない操作への対処
ケンタが気づいた。「RefundCommand#undo でStripeの返金キャンセルAPIを呼んでいますね。これって……Stripeに実際にリクエストを送るんですか?」
「そうだ。これが補償トランザクションだ。」
補償トランザクションとは、ある操作を無かったことにするための逆向きの操作だ。DBのROLLBACKとは違い、アプリケーションレベルで「反対のことをする」アクションを実行する。
| 操作(execute) | 補償操作(undo) |
|---|---|
| Stripeで返金 | Stripeで返金キャンセル |
| 在庫を増やす | 在庫を減らす |
| ポイントを引く | ポイントを戻す |
| メール送信 | 「キャンセルしました」メールを別途送信 |
WARNING
補償トランザクションは「完全な取り消し」ではなく「影響を相殺する別の操作」。メール送信は物理的に取り消せないので、「キャンセルのご連絡」メールを送ることで補償する。undo が設計上不可能な操作もあることを意識する。
「メールはundoできないんですね……」
「だからundoが必要な操作はできるだけ後回しにする。メール送信は全部のCommandが成功したあとの最終ステップにする。それも設計だ。」
Invoker — Commandを実行して履歴を管理する
# app/commands/command_executor.rb
class CommandExecutor
def initialize
@history = []
end
def execute_all(commands)
commands.each_with_index do |command, index|
command.execute
@history << command
rescue StandardError => e
Rails.logger.error("Command失敗 at index #{index}: #{e.message}")
rollback_executed
raise e
end
true
end
private
def rollback_executed
@history.reverse_each do |command|
command.undo
rescue StandardError => e
# undoの失敗は記録するが、他のundoは続行する
Rails.logger.error("undo失敗: #{command.class.name} - #{e.message}")
end
end
end# app/services/order_cancellation_service.rb
class OrderCancellationService
def initialize(order:)
@order = order
end
def call
commands = [
RefundCommand.new(order: @order, amount: @order.total_price),
RestockCommand.new(order: @order),
RevokePointsCommand.new(order: @order),
]
executor = CommandExecutor.new
executor.execute_all(commands)
@order.update!(status: :cancelled)
{ success: true }
rescue StandardError => e
# executorが自動的にundoを実行済み
{ success: false, error: e.message }
end
endINFO
どこかのCommandが失敗したとき、CommandExecutor が自動的に実行済みのCommandを逆順に undo する。3番目が失敗したら「2→1」の順でundoされる。これで「途中エラー時の全ロールバック」が実現できる。
ActiveJob — RailsのCommandパターン実装
「先輩、ActiveJobってCommandパターンなんですか?」
「まさに。気づいたか。」山田さんは嬉しそうだった。
ActiveJobはCommandパターンの最もよく使われる実装だ。perform_later でCommandをキューに積み、Sidekiq/GoodJobがInvokerとして取り出して実行する。
# app/jobs/order_notification_job.rb
class OrderNotificationJob < ApplicationJob
queue_as :notifications
retry_on ActiveRecord::RecordNotFound, wait: 30.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
def perform(order_id)
order = Order.find(order_id)
OrderMailer.confirmation(order).deliver_now
end
end
# Command をキューに積む
OrderNotificationJob.perform_later(order.id)
# 5分後に実行(遅延 Command)
OrderNotificationJob.set(wait: 5.minutes).perform_later(order.id)
# 明日の朝8時に実行(スケジュール Command)
OrderNotificationJob.set(wait_until: Date.tomorrow.beginning_of_day + 8.hours).perform_later(order.id)ActiveJobはまさに「操作のオブジェクト化」だ。
perform_later= Commandをキューに積む- Sidekiq/GoodJob = Commandを取り出して実行するInvoker
retry_on= 失敗時の再試行ポリシーdiscard_on= 特定エラーでは再試行しない
# Sidekiq で優先度別キューを設定する例
# config/sidekiq.yml
# queues:
# - [critical, 3] # 決済系は優先度3倍
# - [default, 2]
# - [notifications, 1]
class PaymentJob < ApplicationJob
queue_as :critical # 優先キューに積む
sidekiq_options retry: 5, backtrace: true
def perform(order_id, amount)
order = Order.find(order_id)
PaymentService.new(order).charge(amount)
end
endINFO
Sidekiq vs GoodJob の使い分け: Sidekiqはパフォーマンスが高いがRedisが必要。GoodJobはPostgreSQLをキューとして使うのでインフラがシンプル。小〜中規模ならGoodJobで十分なことが多い。
Commandをデータベースに永続化する — 監査ログとイベントソーシング
「CommandExecutorの履歴はメモリにしかないですね。サーバーが落ちたら消える……」
「いい観点だ。だから本番ではCommandをDBに保存する。これが監査ログ、さらに発展するとイベントソーシングになる。」
# app/models/command_log.rb
class CommandLog < ApplicationRecord
belongs_to :user
belongs_to :target, polymorphic: true
# columns:
# - command_type: string (例: "RefundCommand")
# - payload: jsonb (コマンドのパラメータ)
# - status: string (pending/executed/undone/failed)
# - executed_at: datetime
# - error_message: text
enum :status, { pending: 0, executed: 1, undone: 2, failed: 3 }
end# paper_trail gem を使った変更履歴との連携
# Gemfile: gem 'paper_trail'
class Product < ApplicationRecord
has_paper_trail
# paper_trail が自動で versions テーブルに変更を記録する
end
# 過去の状態に戻す
product = Product.find(1)
product.paper_trail.version_at(1.hour.ago) # 1時間前の状態を取得
product.versions.last.reify.save! # 1つ前の状態に戻すINFO
paper_trail gem はActiveRecordの変更を自動で versions テーブルに記録する。Commandの undo を自分で実装しなくても reify で過去の状態に戻せる。「誰がいつ何を変えたか」の監査ログにもなる。
Commandパターンと操作履歴(undo/redo)
アプリにundo/redo機能を追加したい場合:
# app/services/undo_redo_service.rb
class UndoRedoService
def initialize(user:)
@user = user
@undo_stack = []
@redo_stack = []
end
def execute(command)
command.execute
@undo_stack.push(command)
@redo_stack.clear # 新しい操作でredoスタックはクリア
end
def undo
return if @undo_stack.empty?
command = @undo_stack.pop
command.undo
@redo_stack.push(command)
end
def redo
return if @redo_stack.empty?
command = @redo_stack.pop
command.execute
@undo_stack.push(command)
end
def can_undo? = !@undo_stack.empty?
def can_redo? = !@redo_stack.empty?
end# 使い方: ブログ記事の編集画面
service = UndoRedoService.new(user: current_user)
# 操作を実行
service.execute(AssignTagCommand.new(post: post, tag: tag))
service.execute(PublishPostCommand.new(post: post))
# 取り消し(Ctrl+Z 相当)
service.undo if service.can_undo?
# やり直し(Ctrl+Y 相当)
service.redo if service.can_redo?この仕組みはちょうど積み重なったトレイのようなものだ。新しい操作はトレイを上に積む。Undoはトレイを上から取り除いてRedoスタックに移す。Redoはそのトレイを元の場所に戻す。
WARNING
undo/redo 機能はスタックをメモリで管理するため、サーバーが落ちるとリセットされる。永続化が必要なら CommandLog にシリアライズして保存し、ページロード時に復元する設計が必要になる。
マクロコマンド — Commandを組み合わせる
複数のCommandを1つのCommandとして扱う:
# app/commands/macro_command.rb
class MacroCommand < BaseCommand
def initialize(commands)
@commands = commands
@executed = []
end
def execute
@commands.each do |command|
command.execute
@executed << command
end
end
def undo
@executed.reverse_each(&:undo)
@executed.clear
end
end# Before: バラバラに呼ぶ
refund_cmd = RefundCommand.new(order: order, amount: order.total_price)
restock_cmd = RestockCommand.new(order: order)
revoke_cmd = RevokePointsCommand.new(order: order)
refund_cmd.execute
restock_cmd.execute
revoke_cmd.execute
# エラーが起きても誰も取り消してくれない
# After: MacroCommandでまとめる
cancellation = MacroCommand.new([
RefundCommand.new(order: order, amount: order.total_price),
RestockCommand.new(order: order),
RevokePointsCommand.new(order: order),
])
cancellation.execute # 全部実行、途中エラーで全undo
cancellation.undo # まとめて全部取り消しマクロコマンドはリモコンのマクロ機能と同じだ。「おやすみモード」ボタン1つで「テレビOFF・エアコンOFF・照明暗く」を一括実行する。それぞれの操作を1つのCommandとしてまとめた組み合わせだ。
テスト
# spec/commands/refund_command_spec.rb
RSpec.describe RefundCommand do
let(:order) { create(:order, status: :paid, payment_id: "ch_123") }
let(:command) { described_class.new(order: order, amount: 5000) }
describe "#execute" do
before do
allow_any_instance_of(StripeGateway).to receive(:refund).and_return(
{ refund_id: "re_456" }
)
end
it "返金IDをorderに保存する" do
command.execute
expect(order.reload.refund_id).to eq("re_456")
end
it "executed? が true になる" do
command.execute
expect(command.executed?).to be true
end
end
describe "#undo" do
context "executeした後" do
before do
allow_any_instance_of(StripeGateway).to receive(:refund)
.and_return({ refund_id: "re_456" })
allow_any_instance_of(StripeGateway).to receive(:cancel_refund)
command.execute
end
it "返金キャンセルAPIを呼ぶ" do
expect_any_instance_of(StripeGateway).to receive(:cancel_refund)
.with(refund_id: "re_456")
command.undo
end
it "executed? が false に戻る" do
command.undo
expect(command.executed?).to be false
end
end
context "executeしていない場合" do
it "何もしない(エラーを出さない)" do
expect { command.undo }.not_to raise_error
end
end
end
end# spec/commands/command_executor_spec.rb
RSpec.describe CommandExecutor do
it "途中で失敗したとき実行済みCommandを逆順でundoする" do
cmd1 = instance_double(BaseCommand, execute: nil, undo: nil)
cmd2 = instance_double(BaseCommand, execute: nil, undo: nil)
cmd3 = instance_double(BaseCommand)
allow(cmd3).to receive(:execute).and_raise(RuntimeError, "DB error")
expect(cmd2).to receive(:undo).ordered
expect(cmd1).to receive(:undo).ordered
expect {
described_class.new.execute_all([cmd1, cmd2, cmd3])
}.to raise_error(RuntimeError)
end
endINFO
CommandはPure Objectなのでテストが書きやすい。外部依存(Stripe、メール)はdoubleで差し替えられる。execute/undoの対称性(実行したら戻せる)をテストで保証しておくと安全。
AWSでのCommand的発想
SQS(Simple Queue Service) はCommandパターンそのものだ。
- SQSキュー = Commandを蓄積する場所
- Lambda/ECS = Commandを取り出して実行するInvoker
- Dead Letter Queue(DLQ) = 規定回数失敗したCommandを退避させる場所
- メッセージの可視性タイムアウト = Commandの実行ロック(他のConsumerが同じCommandを実行しないようにする)
EventBridge Scheduler は「特定の時刻にCommandを実行する」ための仕組みで、ActiveJobの set(wait_until:) に対応する。DLQ(Dead Letter Queue)は規定回数の再試行後に失敗したCommandを退避し、後から原因を調査・再処理できる。
INFO
SQS + DLQ の構成は、ActiveJob + Sidekiq の「再試行(retry)+ デッドジョブキュー」に対応する。AWSではインフラレベルでCommandパターンが実装されている。
EventSourcingへの発展
「Commandの履歴を全部DBに保存したら……それってどんな状態にも戻れますよね?」
「それがEventSourcingだ。」
EventSourcingは「現在の状態 = 最初の状態 + 全Commandの適用」で表現するアーキテクチャだ。
# 通常: 「現在の状態」だけ保存(経緯が消える)
order.update!(status: :cancelled, total_price: 0)
# EventSourcing: 「何が起きたか」を全て保存
[
{ event_type: "order_placed", payload: { total: 5000 } },
{ event_type: "payment_completed",payload: { stripe_id: "ch_123" } },
{ event_type: "order_cancelled", payload: { reason: "user_request" } },
].each { |attrs| OrderEvent.create!(order: order, **attrs, occurred_at: Time.current) }
# 任意の時点の状態を再現できる
order.rebuild_state_at(1.hour.ago)EventSourcingはCommandパターンの自然な発展形だ。Commandの execute がEventを生成し、そのEventの列が全履歴になる。
ケンタの気づき
その夜、ケンタはコードを書き直した。3つの処理をそれぞれCommandオブジェクトにして、CommandExecutor に渡す形にした。
翌朝、山田さんに見せると一言言った。「いいね。テストもちゃんと書けてる。」
ケンタはある感覚を言葉にしようとした。
「先輩、なんか……コードが整理された感じがします。前はOrderCancellationServiceの中に返金処理・在庫処理・ポイント処理が全部べた書きだったんですが、今はそれぞれが独立した『部品』になって。」
「それが設計の醍醐味だよ。関心を分離して、それぞれが交換可能になる。明日、返金方法がStripeからPAYとに変わっても、RefundCommandだけ差し替えればいい。CommandExecutorもOrderCancellationServiceも触らない。」
「操作を一級市民にする、ってそういうことか。」
「そうだ。変数に入れられる。リストに積める。後で実行できる。取り消せる。操作がデータになるんだ。そうなった瞬間、可能性が一気に広がる。」
ケンタはノートにメモした。
Commandパターン = 操作をオブジェクトとして表現。後で実行・取り消し・履歴管理・永続化ができる。RailsではActiveJobが最も一般的な実装。「操作を一級市民にする」ことで、設計の自由度が劇的に上がる。
INFO
この章のまとめ
- DBトランザクションだけでは外部API(Stripe等)のロールバックはできない。これがCommandパターンが必要な理由
- Commandパターンは操作(execute)と補償操作(undo)をセットでカプセル化する
- CommandExecutorが実行履歴を管理し、失敗時に逆順でundoする(補償トランザクション)
- RailsのActiveJobはCommandパターンの最も実用的な実装(Sidekiq/GoodJobがInvoker)
- CommandをDBに保存することで監査ログ・paper_trail連携・EventSourcingへ発展できる
- SQS + DLQはAWSにおけるCommandパターンの実装。失敗Commandは自動的にDLQへ退避される
- 「操作を一級市民にする」ことで、遅延実行・undo/redo・マクロ化・永続化が可能になる