mybook

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オブジェクトとして記録されているからだ。

Loading diagram...

Commandパターンで何が嬉しいか:

  1. 操作を キュー に入れて後で実行できる(非同期処理)
  2. 操作の 履歴を記録 できる(undo/redo)
  3. 操作を 組み合わせ られる(マクロコマンド)
  4. 失敗時に 逆順で取り消し できる(補償トランザクション)

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
end
Loading diagram...

INFO

どこかの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
end

INFO

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 で過去の状態に戻せる。「誰がいつ何を変えたか」の監査ログにもなる。

Loading diagram...

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
end

INFO

CommandはPure Objectなのでテストが書きやすい。外部依存(Stripe、メール)はdoubleで差し替えられる。execute/undoの対称性(実行したら戻せる)をテストで保証しておくと安全。

AWSでのCommand的発想

Loading diagram...

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だけ差し替えればいい。CommandExecutorOrderCancellationServiceも触らない。」

操作を一級市民にする、ってそういうことか。」

「そうだ。変数に入れられる。リストに積める。後で実行できる。取り消せる。操作がデータになるんだ。そうなった瞬間、可能性が一気に広がる。」

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

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・マクロ化・永続化が可能になる