mybook

Chain of Responsibility — 処理を連鎖させる

「バリデーションが複雑になってきた」

ケンタは注文受付処理の改修を担当していた。

「山田さん、注文を受け付ける前のチェックがどんどん増えてきて……コントローラがひどいことになってます」

class OrdersController < ApplicationController
  def create
    # チェック1: 会員資格の確認
    unless current_user.active_member?
      return render json: { error: "有効な会員資格が必要です" }, status: :forbidden
    end
 
    # チェック2: 在庫確認
    params[:items].each do |item|
      product = Product.find(item[:product_id])
      unless product.stock >= item[:quantity]
        return render json: { error: "在庫不足: #{product.name}" }, status: :unprocessable_entity
      end
    end
 
    # チェック3: 不正注文検知
    recent_orders = current_user.orders.where("created_at > ?", 1.hour.ago).count
    if recent_orders >= 5
      return render json: { error: "短時間に多数の注文が検出されました" }, status: :forbidden
    end
 
    # チェック4: 与信確認
    outstanding = current_user.unpaid_orders.sum(:total_price)
    total = calculate_total(params[:items])
    if outstanding + total > current_user.credit_limit
      return render json: { error: "与信限度額を超えています" }, status: :unprocessable_entity
    end
 
    # チェック5: 配送先の確認(新しく追加された)
    unless current_user.has_valid_shipping_address?
      return render json: { error: "有効な配送先住所が必要です" }, status: :unprocessable_entity
    end
 
    # 本来の処理……
    order = OrderCreationService.new(user: current_user, items: params[:items]).call
    render json: { order_id: order.id }
  end
end

「これ、チェック項目が増えるたびにコントローラを触る必要がありますね。しかも全部が1メソッドに混ざっている。」

山田さんはコードを見て頷いた。「Chain of Responsibilityパターンだ。チェック処理をチェーンとして組み立てる。コントローラは結果だけ受け取ればいい。」

「チェーン……鎖ですか?」

「そう。会員確認 → 在庫確認 → 不正検知 → 与信確認 → 配送先確認、という鎖が順番にリクエストを処理する。各チェックは独立したクラスになる。」

Chain of Responsibility とは

Chain of Responsibility パターンは、複数のハンドラをチェーン(鎖)状につなぎ、リクエストをチェーンに沿って渡していくパターンだ。各ハンドラは「自分が処理できるか」を判断し、処理できなければ次のハンドラに渡す。

日常の比喩はカスタマーサポートのエスカレーションだ。「1次サポート → 2次サポート → 専門チーム → マネージャー」という順序で問い合わせが渡される。各レベルが「これは自分が解決できる」と判断したら処理し、難しければ次のレベルに渡す。問い合わせする側は、最初に窓口に話しかけるだけでいい。

もう一つの比喩は空港のセキュリティチェックだ。「チケット確認 → パスポート確認 → 手荷物X線 → 金属探知機」という順序で通過する。どこかで引っかかれば止められる。全部通過して初めて搭乗口に進める。各チェックポイントは独立していて、後から「靴脱ぎ検査」を追加しやすい。

Loading diagram...

実装

まず、ハンドラの基底クラスを定義する。

# app/handlers/base_order_handler.rb
class BaseOrderHandler
  attr_writer :next_handler
 
  # サブクラスはこのメソッドをオーバーライドし、superを呼んで次へ渡す
  def handle(order_request)
    if @next_handler
      @next_handler.handle(order_request)
    else
      # チェーンの末尾 — 全チェック通過
      Result.success
    end
  end
 
  # メソッドチェーンのためにhandlerを返す
  def set_next(handler)
    @next_handler = handler
    handler
  end
 
  # ショートカット: handlerを設定して自分自身を返す(チェーン構築用)
  def then(handler)
    @next_handler = handler
    self
  end
 
  private
 
  # 結果オブジェクト(ValueObject)
  module Result
    def self.success
      { success: true }
    end
 
    def self.failure(error:, code:, details: {})
      { success: false, error: error, code: code, details: details }
    end
  end
end

各チェックハンドラを独立したクラスとして実装する。

# app/handlers/membership_check_handler.rb
class MembershipCheckHandler < BaseOrderHandler
  def handle(order_request)
    user = order_request[:user]
 
    unless user.active_member?
      return Result.failure(
        error: "有効な会員資格が必要です",
        code: :membership_required,
        details: {
          user_id: user.id,
          membership_status: user.membership_status,
          expired_at: user.membership_expired_at&.iso8601
        }
      )
    end
 
    # チェック通過:次のハンドラへ
    super
  end
end
# app/handlers/stock_check_handler.rb
class StockCheckHandler < BaseOrderHandler
  def handle(order_request)
    items = order_request[:items]
    insufficient = []
 
    items.each do |item|
      product = Product.find(item[:product_id])
      if product.stock < item[:quantity]
        insufficient << {
          product_id: product.id,
          product_name: product.name,
          requested: item[:quantity],
          available: product.stock
        }
      end
    end
 
    if insufficient.any?
      return Result.failure(
        error: "一部の商品で在庫が不足しています",
        code: :insufficient_stock,
        details: { insufficient_items: insufficient }
      )
    end
 
    super
  end
end
# app/handlers/fraud_check_handler.rb
class FraudCheckHandler < BaseOrderHandler
  SUSPICIOUS_ORDER_THRESHOLD = 5     # 1時間以内に5件以上
  SUSPICIOUS_AMOUNT_THRESHOLD = 100_000  # 1回10万円以上
 
  def handle(order_request)
    user = order_request[:user]
    total_amount = order_request[:total_amount]
 
    # 頻度チェック
    recent_order_count = user.orders.where("created_at > ?", 1.hour.ago).count
    if recent_order_count >= SUSPICIOUS_ORDER_THRESHOLD
      FraudAlertJob.perform_later(user_id: user.id, reason: "high_frequency")
      return Result.failure(
        error: "短時間に多数の注文が検出されました。しばらく後に再試行してください",
        code: :suspicious_frequency,
        details: { recent_count: recent_order_count, threshold: SUSPICIOUS_ORDER_THRESHOLD }
      )
    end
 
    # 高額注文チェック(初回ユーザーの場合のみ)
    if total_amount >= SUSPICIOUS_AMOUNT_THRESHOLD && user.orders.completed.count.zero?
      FraudAlertJob.perform_later(user_id: user.id, reason: "high_amount_new_user")
      return Result.failure(
        error: "ご本人確認が必要です。カスタマーサポートにお問い合わせください",
        code: :suspicious_high_amount,
        details: { amount: total_amount, threshold: SUSPICIOUS_AMOUNT_THRESHOLD }
      )
    end
 
    super
  end
end
# app/handlers/credit_limit_check_handler.rb
class CreditLimitCheckHandler < BaseOrderHandler
  def handle(order_request)
    user = order_request[:user]
    new_order_amount = order_request[:total_amount]
 
    outstanding = user.unpaid_orders.sum(:total_price)
    available_credit = user.credit_limit - outstanding
 
    if available_credit < new_order_amount
      return Result.failure(
        error: "与信限度額を超えています",
        code: :credit_limit_exceeded,
        details: {
          credit_limit: user.credit_limit,
          outstanding_amount: outstanding,
          available_credit: available_credit,
          requested_amount: new_order_amount
        }
      )
    end
 
    super
  end
end
# app/handlers/shipping_address_check_handler.rb
class ShippingAddressCheckHandler < BaseOrderHandler
  def handle(order_request)
    user = order_request[:user]
    shipping_address_id = order_request[:shipping_address_id]
 
    address = if shipping_address_id
                user.addresses.find_by(id: shipping_address_id)
              else
                user.default_address
              end
 
    unless address
      return Result.failure(
        error: "有効な配送先住所が指定されていません",
        code: :no_shipping_address
      )
    end
 
    unless address.deliverable_area?
      return Result.failure(
        error: "指定された住所への配送には対応していません",
        code: :non_deliverable_area,
        details: { prefecture: address.prefecture }
      )
    end
 
    # 確認済みの住所をリクエストに追加して次へ渡す
    order_request[:validated_address] = address
    super
  end
end

チェーンを組み立てる

# app/services/order_validation_chain.rb
class OrderValidationChain
  # 軽いチェック(DBアクセスなし)を先に、重いチェックを後に
  def self.build
    membership  = MembershipCheckHandler.new
    stock       = StockCheckHandler.new
    fraud       = FraudCheckHandler.new
    credit      = CreditLimitCheckHandler.new
    shipping    = ShippingAddressCheckHandler.new
 
    # set_nextはhandlerを返すのでチェーン構築できる
    membership.set_next(stock).set_next(fraud).set_next(credit).set_next(shipping)
 
    membership  # チェーンの先頭を返す
  end
 
  # デバッグ用: チェーンの構造を文字列で返す
  def self.describe
    [
      "1. MembershipCheckHandler(会員資格確認)",
      "2. StockCheckHandler(在庫確認)",
      "3. FraudCheckHandler(不正注文検知)",
      "4. CreditLimitCheckHandler(与信限度額確認)",
      "5. ShippingAddressCheckHandler(配送先確認)"
    ].join(" → ")
  end
end
# app/controllers/orders_controller.rb(スッキリしたコントローラ)
class OrdersController < ApplicationController
  before_action :authenticate_user!
 
  def create
    order_request = build_order_request
 
    # バリデーションチェーンを実行
    chain = OrderValidationChain.build
    result = chain.handle(order_request)
 
    unless result[:success]
      return render json: {
        error: result[:error],
        code: result[:code],
        details: result[:details]
      }, status: error_status(result[:code])
    end
 
    # バリデーション通過後の本来の処理
    order = OrderCreationService.new(
      user: current_user,
      items: params[:items],
      address: order_request[:validated_address]
    ).call
 
    render json: { order_id: order.id, message: "注文を受け付けました" }, status: :created
  end
 
  private
 
  def build_order_request
    {
      user: current_user,
      items: order_params[:items],
      shipping_address_id: order_params[:shipping_address_id],
      total_amount: calculate_total(order_params[:items])
    }
  end
 
  def calculate_total(items)
    items.sum { |item| Product.find(item[:product_id]).price * item[:quantity].to_i }
  end
 
  def error_status(code)
    case code
    when :membership_required, :suspicious_frequency, :suspicious_high_amount
      :forbidden
    when :insufficient_stock, :credit_limit_exceeded, :no_shipping_address,
         :non_deliverable_area
      :unprocessable_entity
    else
      :unprocessable_entity
    end
  end
 
  def order_params
    params.require(:order).permit(
      :shipping_address_id,
      items: [:product_id, :quantity]
    )
  end
end

INFO

チェーンの順序が重要だ。「軽いチェック(DBアクセスなし・メモリのみ)を先に」「重いチェック(外部API・複雑なクエリ)を後に」とすることで、早期に弾かれる確率が上がり、全体のパフォーマンスが向上する。会員確認は1クエリ、在庫確認は全商品分のクエリが必要——だから会員確認を先に実行する。

Rackミドルウェア — Railsが使うChain of Responsibility

RailsのRackスタックはChain of Responsibilityの典型例だ。Railsアプリへのリクエストはこのチェーンを全て通過する。

# rails middleware コマンドでチェーンを確認できる
$ rails middleware
 
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run MyApp::Application.routes

各ミドルウェアは「自分の処理をして次を呼ぶ」パターンだ。Rackの仕様は非常にシンプルで、call(env)を実装するだけでいい。

# Rackミドルウェアの基本形
class MyMiddleware
  def initialize(app)
    @app = app  # 次のミドルウェア(チェーンの次)
  end
 
  def call(env)
    # 前処理(リクエストフェーズ)
    before_request(env)
 
    # 次のミドルウェアに処理を渡す
    status, headers, body = @app.call(env)
 
    # 後処理(レスポンスフェーズ)
    after_request(status, headers, body)
 
    [status, headers, body]
  end
 
  private
 
  def before_request(env)
    # リクエストのロギング、認証チェックなど
  end
 
  def after_request(status, headers, body)
    # レスポンスヘッダーの追加、圧縮など
  end
end

自作ミドルウェアを追加する例:

# lib/middleware/maintenance_mode_middleware.rb
class MaintenanceModeMiddleware
  MAINTENANCE_KEY = "system:maintenance_mode"
  WHITELIST_PATHS = ["/health", "/api/v1/status"].freeze
 
  def initialize(app)
    @app = app
  end
 
  def call(env)
    request = Rack::Request.new(env)
 
    # ホワイトリストのパスはメンテナンス中も通す
    return @app.call(env) if whitelist_path?(request.path)
 
    if maintenance_mode?
      maintenance_response
    else
      @app.call(env)  # 通常はチェーンを続ける
    end
  end
 
  private
 
  def whitelist_path?(path)
    WHITELIST_PATHS.any? { |whitelist| path.start_with?(whitelist) }
  end
 
  def maintenance_mode?
    # Redisから読み取る(Railsのキャッシュストアを直接使う)
    Rails.cache.read(MAINTENANCE_KEY) == true
  end
 
  def maintenance_response
    body = File.read(Rails.root.join("public/maintenance.html")) rescue "Maintenance in progress"
    [
      503,
      {
        "Content-Type" => "text/html; charset=utf-8",
        "Retry-After" => "3600"
      },
      [body]
    ]
  end
end
# config/application.rb
module MyApp
  class Application < Rails::Application
    # メンテナンスモードをできるだけ早い段階で挟む
    config.middleware.insert_before ActionDispatch::Static, MaintenanceModeMiddleware
  end
end
# レート制限ミドルウェア(Proxyチェーンの追加例)
class RateLimitMiddleware
  LIMIT_PER_MINUTE = 60
  LIMIT_KEY_PREFIX = "rate_limit"
 
  def initialize(app)
    @app = app
  end
 
  def call(env)
    request = Rack::Request.new(env)
    ip = request.ip
    key = "#{LIMIT_KEY_PREFIX}:#{ip}:#{Time.current.strftime('%Y%m%d%H%M')}"
 
    count = Rails.cache.increment(key, 1, expires_in: 2.minutes)
 
    if count > LIMIT_PER_MINUTE
      return [
        429,
        {
          "Content-Type" => "application/json",
          "Retry-After" => "60",
          "X-RateLimit-Limit" => LIMIT_PER_MINUTE.to_s,
          "X-RateLimit-Remaining" => "0"
        },
        ['{"error":"Too Many Requests"}']
      ]
    end
 
    status, headers, body = @app.call(env)
    headers["X-RateLimit-Limit"] = LIMIT_PER_MINUTE.to_s
    headers["X-RateLimit-Remaining"] = [LIMIT_PER_MINUTE - count, 0].max.to_s
    [status, headers, body]
  end
end

より柔軟な実装: パイプラインパターン

チェーンをよりシンプルに実装するバリエーション。Procやlambdaを使う。

# app/services/validation_pipeline.rb
class ValidationPipeline
  Step = Struct.new(:name, :handler, keyword_init: true)
 
  def initialize
    @steps = []
  end
 
  def add(name, &block)
    @steps << Step.new(name: name, handler: block)
    self
  end
 
  def run(context)
    @steps.each do |step|
      result = step.handler.call(context)
 
      unless result[:success]
        Rails.logger.info("[ValidationPipeline] #{step.name} でエラー: #{result[:error]}")
        return result
      end
 
      Rails.logger.debug("[ValidationPipeline] #{step.name} 通過")
    end
 
    { success: true }
  end
 
  def step_names
    @steps.map(&:name)
  end
end
# 使い方(ラムダで各ステップを定義)
pipeline = ValidationPipeline.new
  .add("会員資格確認") { |ctx|
    ctx[:user].active_member? ? { success: true } : { success: false, error: "会員資格なし", code: :membership_required }
  }
  .add("在庫確認") { |ctx|
    insufficient = ctx[:items].reject { |item| Product.find(item[:product_id]).stock >= item[:quantity] }
    insufficient.empty? ? { success: true } : { success: false, error: "在庫不足", code: :insufficient_stock }
  }
  .add("不正注文検知") { |ctx|
    count = ctx[:user].orders.where("created_at > ?", 1.hour.ago).count
    count < 5 ? { success: true } : { success: false, error: "注文頻度が高すぎます", code: :suspicious_frequency }
  }
 
result = pipeline.run({ user: current_user, items: params[:items] })

テスト

各ハンドラは独立してテストできる。これがChain of Responsibilityの利点の一つだ。

# spec/handlers/stock_check_handler_spec.rb
RSpec.describe StockCheckHandler do
  subject(:handler) { described_class.new }
 
  let(:product_a) { create(:product, stock: 10) }
  let(:product_b) { create(:product, stock: 5) }
 
  describe "#handle" do
    context "在庫が十分な場合" do
      let(:request) do
        {
          user: create(:user, :active_member),
          items: [
            { product_id: product_a.id, quantity: 5 },
            { product_id: product_b.id, quantity: 3 }
          ]
        }
      end
 
      it "次のハンドラに処理を渡す(成功を返す)" do
        result = handler.handle(request)
        expect(result[:success]).to be true
      end
    end
 
    context "一部の商品で在庫が不足している場合" do
      let(:request) do
        {
          user: create(:user, :active_member),
          items: [
            { product_id: product_a.id, quantity: 5 },   # 在庫: 10, 問題なし
            { product_id: product_b.id, quantity: 10 }   # 在庫: 5, 不足!
          ]
        }
      end
 
      it "エラーを返す" do
        result = handler.handle(request)
        expect(result[:success]).to be false
        expect(result[:code]).to eq(:insufficient_stock)
      end
 
      it "不足商品の詳細を返す" do
        result = handler.handle(request)
        expect(result[:details][:insufficient_items]).to have(1).item
        expect(result[:details][:insufficient_items].first[:product_id]).to eq(product_b.id)
      end
    end
 
    context "全商品で在庫が不足している場合" do
      let(:request) do
        {
          user: create(:user),
          items: [{ product_id: product_b.id, quantity: 100 }]
        }
      end
 
      it "不足商品の詳細が含まれる" do
        result = handler.handle(request)
        expect(result[:details][:insufficient_items].first[:requested]).to eq(100)
        expect(result[:details][:insufficient_items].first[:available]).to eq(5)
      end
    end
 
    context "次のハンドラが設定されている場合" do
      let(:next_handler) { instance_double(FraudCheckHandler) }
      let(:request) do
        {
          user: create(:user),
          items: [{ product_id: product_a.id, quantity: 1 }]
        }
      end
 
      before do
        handler.set_next(next_handler)
        allow(next_handler).to receive(:handle).and_return({ success: true })
      end
 
      it "在庫チェック通過後に次のハンドラを呼ぶ" do
        handler.handle(request)
        expect(next_handler).to have_received(:handle).with(request)
      end
    end
  end
end
# spec/services/order_validation_chain_spec.rb
RSpec.describe OrderValidationChain do
  describe ".build" do
    let(:chain) { described_class.build }
 
    it "チェーンのインスタンスを返す" do
      expect(chain).to be_a(MembershipCheckHandler)
    end
  end
 
  describe "統合テスト: 全チェーンを通過する場合" do
    let(:user) { create(:user, :active_member, credit_limit: 100_000) }
    let(:product) { create(:product, stock: 10, price: 1000) }
    let(:address) { create(:address, user: user, deliverable: true) }
 
    let(:valid_request) do
      {
        user: user,
        items: [{ product_id: product.id, quantity: 2 }],
        shipping_address_id: address.id,
        total_amount: 2000
      }
    end
 
    it "全チェックを通過して成功を返す" do
      result = OrderValidationChain.build.handle(valid_request)
      expect(result[:success]).to be true
    end
  end
 
  describe "統合テスト: 会員資格なしで失敗する場合" do
    let(:user) { create(:user, :inactive_member) }
 
    let(:request) do
      {
        user: user,
        items: [{ product_id: create(:product).id, quantity: 1 }],
        total_amount: 1000
      }
    end
 
    it "最初のハンドラで止まる" do
      result = OrderValidationChain.build.handle(request)
      expect(result[:success]).to be false
      expect(result[:code]).to eq(:membership_required)
    end
  end
end

WARNING

チェーンが長くなりすぎると、どのハンドラが処理しているか追いにくくなる。ValidationPipelineのように実行時にログを出力するか、デバッグモードで各ハンドラの処理をトレースする仕組みを設けよう。本番でのトラブルシューティング時に「どのチェックで止まったのか」がすぐわかると助かる。

AWSでのChain of Responsibility

ALB(Application Load Balancer)のリスナールール はChain of Responsibilityだ。

リスナールール(優先度順に評価される):
  ルール 1: パスが /api/* かつ X-API-Key ヘッダーが存在する → APIターゲットグループ
  ルール 2: パスが /admin/* → 管理サーバーターゲットグループ + IP制限(社内のみ)
  ルール 3: User-Agentが "Googlebot" を含む → SEO最適化レスポンスグループ
  ルール 4: X-Mobile: true ヘッダーが存在する → モバイル最適化グループ
  ルール 5: デフォルト → メインWebサーバーグループ

各ルールが「自分の条件に合うか?」を確認し、合えば対応するターゲットに転送し、合わなければ次のルールへ。これはまさにChain of Responsibilityだ。

WAF(Web Application Firewall)のルールグループも連鎖的に処理する。

WAFルールグループ(前から順に評価):
  1. AWS-AWSManagedRulesIPReputationList(悪意のあるIPブロック)
  2. AWS-AWSManagedRulesCommonRuleSet(一般的なウェブ攻撃の防御)
  3. AWS-AWSManagedRulesSQLiRuleSet(SQLインジェクション検知)
  4. AWS-AWSManagedRulesKnownBadInputsRuleSet(既知の悪意ある入力ブロック)
  5. カスタムルール: レート制限(IPごとに1分間100リクエストまで)

「SQLインジェクション検知 → XSS検知 → レートリミット → IPブラックリスト」という順序でリクエストをチェックする。いずれかで引っかかれば処理を止め、全部通過すればアプリケーションへ転送する。

CloudWatch Logs Insightsのクエリもフィルター → 統計 → ソートというチェーン処理の発想で動く。

# CloudWatch Logs Insightsでエラーを調査
fields @timestamp, @message, @logStream
| filter @message like /ERROR/          # チェーン1: エラーログのみ
| filter @logStream like /production/   # チェーン2: 本番環境のみ
| stats count() by bin(5m)              # チェーン3: 5分ごとに集計
| sort @timestamp desc                  # チェーン4: 新しい順にソート
| limit 20                              # チェーン5: 最大20件

ケンタの気づき

コードレビューを提出すると、山田さんから返事が来た。

「コントローラがすっきりした。バリデーションハンドラは独立したクラスになっているので、新しいチェックを追加するときOrderValidationChain.buildに1行足すだけでいい。既存のハンドラは一切変更不要。」

「Chain of Responsibilityって、RackミドルウェアとかALBのルールとか、普段から使ってたんですね。名前を知らなかっただけで。」

「そうだ」と山田さんは言った。「パターンを知る前から使っていた。名前を知ることで、設計の議論ができるようになる。『ここはChain of Responsibilityで実装しましょう』と言えれば、チームで設計が共有できる。コードを書く前に認識を合わせられる。」

「チェーンの順序が重要なのと、どのハンドラで止まったかをログに残すのが大事ですね。」

「そうだ。デバッグの難しさがこのパターンの弱点だ。実行ログをしっかり残すことが重要。それさえできれば、追加・削除・順序変更が自由にできる柔軟なシステムになる。」

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

Chain of Responsibilityパターン = リクエストをハンドラのチェーンに通す。各ハンドラが処理するか次に渡すかを判断。軽いチェックを先に。Rackミドルウェアはその代表例。新しいチェックの追加がコード修正なしで可能。


INFO

この章のまとめ

  • Chain of Responsibilityはリクエストをハンドラのチェーンに渡すパターン
  • 各ハンドラは独立したクラスとして実装され、単独でテストできる
  • チェーンの先頭ハンドラを呼ぶだけで全チェックが実行される
  • 新しいチェックの追加は、新クラスを作ってbuildメソッドに追加するだけ(既存コード変更なし)
  • チェーンの順序は「軽いチェックを先に」でパフォーマンス最適化
  • Rackミドルウェアスタックがその最も有名な実装例
  • ALBリスナールール・WAFルールもChain of Responsibilityの発想