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線 → 金属探知機」という順序で通過する。どこかで引っかかれば止められる。全部通過して初めて搭乗口に進める。各チェックポイントは独立していて、後から「靴脱ぎ検査」を追加しやすい。
実装
まず、ハンドラの基底クラスを定義する。
# 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
endINFO
チェーンの順序が重要だ。「軽いチェック(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
endWARNING
チェーンが長くなりすぎると、どのハンドラが処理しているか追いにくくなる。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の発想