Strategy パターン — アルゴリズムを交換可能にする
「また仕様変更です」
プルリクをマージしてから2週間後、ケンタのSlackに新しいメッセージが届いた。
「配送料計算に『法人会員』を追加してほしい。あと来月から『学生会員』も。半年後には海外配送も対応予定です。」
ケンタはコントローラを開き、あの長いif-else地獄を見てため息をついた。
またここを触るのか……。しかも海外配送まで追加されたら、このメソッドが100行を超えてしまう。
恐る恐る山田さんのデスクに向かった。「山田さん、仕様変更があって、またif-elseを追加しないといけないんですが……プロローグで話していたコードの匂いが、すでにしてます。」
山田さんはニヤリと笑った。「ちょうどいい機会だ。Strategyパターンを教えよう。」
Strategy パターンとは
Strategy パターンは、アルゴリズムを定義し、それぞれをカプセル化して、互いに交換可能にするパターンだ。
日常の比喩で言えば、カーナビアプリの経路探索 に近い。「徒歩」「車」「電車」「自転車」という4つの「戦略」があり、ユーザーは目的地を変えずに戦略だけを切り替えられる。カーナビのアプリ本体(コンテキスト)は、どの戦略が使われるかを気にしない。ユーザーが「電車」を選ぼうが「車」を選ぼうが、アプリの構造は変わらない。
別の比喩で言えば、スポーツの戦術 だ。バスケットボールのチームが相手チームによって「攻撃的戦術」「守備的戦術」「オールコートプレス戦術」を使い分けるように、コンテキスト(チーム)は同じで、戦略だけが切り替わる。
肝心な点:コンテキストは具体的な戦略を知らない。インターフェース(共通のメソッド)だけを知っている。これにより、新しい戦略を追加しても、コンテキストのコードは変わらない。
問題のあるコード(Before)
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def calculate_shipping
order = Order.find(params[:id])
# 条件分岐の地獄 — 新しい会員種別が追加されるたびに膨張する
if order.user.premium?
if order.total_price > 10000
shipping_fee = 0
else
shipping_fee = 300
end
elsif order.user.regular?
if order.total_price > 5000
shipping_fee = 500
else
shipping_fee = 800
end
elsif order.user.corporate? # 今回追加
shipping_fee = order.total_price > 30000 ? 0 : 600
elsif order.user.student? # 来月追加予定
shipping_fee = 400
else
if order.destination == "hokkaido" || order.destination == "okinawa"
shipping_fee = 1500
else
shipping_fee = 1000
end
end
render json: { shipping_fee: shipping_fee }
end
endこのコードが抱える問題を、山田さんは3つ挙げた。
開放/閉鎖原則(OCP)の違反:新しい会員種別が追加されるたびに既存コードを修正しなければならない。既存コードを修正するということは、既存の動作を壊すリスクが生まれる。
テストしにくい構造:コントローラのテストで全パターン(会員種別 × 金額 × 目的地の組み合わせ)を網羅しなければならない。計算ロジックだけを独立してテストできない。
責務の混在:コントローラの責務は「リクエストを受けてレスポンスを返すこと」だ。「どの会員種別にいくら請求するか」という計算ロジックをコントローラが知る必要はない。
「ケンタくん、コントローラが全会員種別の計算方法を暗記している状態になっている」と山田さんは言った。「コントローラは社員名簿じゃない。指揮者であるべきで、全楽器の弾き方を知っている必要はない。」
Strategyパターンの適用(After)
「変わる部分」と「変わらない部分」を分けることから始める。
- 変わらない部分:コントローラが「送料を計算して返す」というフロー
- 変わる部分:「どの会員種別にどういう計算をするか」というロジック
変わる部分を別のクラスに切り出す。これがStrategyパターンの核心だ。
ステップ1: 戦略インターフェースを定義する
# app/services/shipping_strategies/base_strategy.rb
module ShippingStrategies
class BaseStrategy
# すべての戦略クラスが実装しなければならないメソッド
def calculate(order)
raise NotImplementedError, "#{self.class}#calculate を実装してください"
end
protected
# 共通ヘルパーメソッドはここに置ける
def remote_area?(destination)
%w[hokkaido okinawa].include?(destination)
end
end
endステップ2: 具体的な戦略を実装する
各戦略クラスは、1つの会員種別の計算ロジックだけを担う。単一責任原則の実践だ。
# app/services/shipping_strategies/premium_strategy.rb
module ShippingStrategies
class PremiumStrategy < BaseStrategy
FREE_SHIPPING_THRESHOLD = 10_000
STANDARD_FEE = 300
def calculate(order)
order.total_price > FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_FEE
end
end
end# app/services/shipping_strategies/regular_strategy.rb
module ShippingStrategies
class RegularStrategy < BaseStrategy
FREE_SHIPPING_THRESHOLD = 5_000
DISCOUNTED_FEE = 500
STANDARD_FEE = 800
def calculate(order)
order.total_price > FREE_SHIPPING_THRESHOLD ? DISCOUNTED_FEE : STANDARD_FEE
end
end
end# app/services/shipping_strategies/corporate_strategy.rb
module ShippingStrategies
class CorporateStrategy < BaseStrategy
FREE_SHIPPING_THRESHOLD = 30_000
STANDARD_FEE = 600
def calculate(order)
order.total_price > FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_FEE
end
end
end# app/services/shipping_strategies/student_strategy.rb
module ShippingStrategies
class StudentStrategy < BaseStrategy
FLAT_FEE = 400
def calculate(order)
FLAT_FEE # 学生は一律400円
end
end
end# app/services/shipping_strategies/guest_strategy.rb
module ShippingStrategies
class GuestStrategy < BaseStrategy
REMOTE_AREA_FEE = 1_500
STANDARD_FEE = 1_000
def calculate(order)
remote_area?(order.destination) ? REMOTE_AREA_FEE : STANDARD_FEE
end
end
endステップ3: 戦略を選択するセレクタを作る
戦略の選択ロジックを1か所に集める。ハッシュマップを使うことで、「どの会員種別にどの戦略を使うか」が一目でわかる。
# app/services/shipping_strategies/strategy_selector.rb
module ShippingStrategies
class StrategySelector
# 会員種別と戦略クラスのマッピングを定数で定義
STRATEGY_MAP = {
"premium" => PremiumStrategy,
"regular" => RegularStrategy,
"corporate" => CorporateStrategy,
"student" => StudentStrategy,
}.freeze
def self.for(user)
strategy_class = STRATEGY_MAP[user.membership_type] || GuestStrategy
strategy_class.new
end
end
endステップ4: コンテキスト(Order)に戦略を持たせる
Orderモデルが「自分の送料を計算する」という責務を持つようにする。
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
def shipping_fee
strategy = ShippingStrategies::StrategySelector.for(user)
strategy.calculate(self)
end
endステップ5: コントローラはシンプルに
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def calculate_shipping
order = Order.find(params[:id])
render json: { shipping_fee: order.shipping_fee }
end
endコントローラが「計算方法を知っている」状態から、「計算を依頼するだけ」の状態になった。コントローラは自分の本来の仕事——リクエストを受けてレスポンスを返すこと——に専念できるようになった。
INFO
コントローラが「計算方法を知っている」状態から、「計算を依頼するだけ」の状態になった。これは「Tell, Don't Ask」の原則にも沿っている。「何かを聞いてから判断する」のではなく、「依頼するだけで結果を受け取る」設計が、責務の分離を実現する。
新しい会員種別の追加が簡単になった
来月追加予定の「海外配送」戦略は、既存コードに一切触れずに追加できる。
# app/services/shipping_strategies/overseas_strategy.rb
module ShippingStrategies
class OverseasStrategy < BaseStrategy
ZONE_FEES = {
"asia" => 2_000,
"america" => 4_000,
"europe" => 5_000,
"other" => 6_000,
}.freeze
def calculate(order)
zone = detect_zone(order.destination_country)
ZONE_FEES.fetch(zone, ZONE_FEES["other"])
end
private
def detect_zone(country_code)
case country_code
when "US", "CA" then "america"
when "GB", "DE", "FR" then "europe"
when "KR", "CN", "TW" then "asia"
else "other"
end
end
end
end# STRATEGY_MAPに1行追加するだけ
STRATEGY_MAP = {
"premium" => PremiumStrategy,
"regular" => RegularStrategy,
"corporate" => CorporateStrategy,
"student" => StudentStrategy,
"overseas" => OverseasStrategy, # ← 追加
}.freezeこれが 開放/閉鎖原則(OCP) だ。「拡張に対して開いていて、修正に対して閉じている」。新しい戦略を追加するとき、既存コードを修正しない。
「すごい!新しいファイルを作るだけで、既存のものは何も変えなくていいんですね。」ケンタは目を丸くした。
「そう。しかも、既存のテストが壊れる心配もない。新しいコードを追加するだけで、既存コードは保護されている。」
テストが格段に書きやすくなった
Before(コントローラのテスト)と After(戦略のテスト)を比較してみよう。
Before: コントローラのテスト(複雑)
# spec/controllers/orders_controller_spec.rb
# 全パターンをコントローラレベルでテストしなければならない
RSpec.describe OrdersController, type: :controller do
describe "GET #calculate_shipping" do
context "プレミアム会員で10000円超の場合" do
# HTTPリクエストをシミュレートする必要がある
# コントローラの全セットアップが必要
end
context "一般会員で5000円以下の場合" do
# ...
end
# 全会員種別 × 全金額パターン = 多数のテストが必要
end
endAfter: 戦略のテスト(シンプル)
# spec/services/shipping_strategies/premium_strategy_spec.rb
RSpec.describe ShippingStrategies::PremiumStrategy do
subject(:strategy) { described_class.new }
describe "#calculate" do
context "合計金額が10000円超のとき" do
let(:order) { build(:order, total_price: 15_000) }
it "送料が無料になる" do
expect(strategy.calculate(order)).to eq(0)
end
end
context "合計金額がちょうど10000円のとき" do
let(:order) { build(:order, total_price: 10_000) }
it "送料が300円になる(境界値)" do
expect(strategy.calculate(order)).to eq(300)
end
end
context "合計金額が10000円未満のとき" do
let(:order) { build(:order, total_price: 8_000) }
it "送料が300円になる" do
expect(strategy.calculate(order)).to eq(300)
end
end
end
end# spec/services/shipping_strategies/guest_strategy_spec.rb
RSpec.describe ShippingStrategies::GuestStrategy do
subject(:strategy) { described_class.new }
describe "#calculate" do
context "北海道への配送" do
let(:order) { build(:order, destination: "hokkaido") }
it "遠隔地料金1500円になる" do
expect(strategy.calculate(order)).to eq(1_500)
end
end
context "東京への配送" do
let(:order) { build(:order, destination: "tokyo") }
it "標準料金1000円になる" do
expect(strategy.calculate(order)).to eq(1_000)
end
end
end
end各戦略クラスは純粋なロジックなので、HTTPリクエストのモックも、データベースも必要ない。テストが速く、書きやすく、読みやすくなった。
INFO
テストが書きやすいコードは、設計が良いコードのサインだ。「このクラスをテストするのに、何を準備しなければならないか」を考えると、設計の問題が見えてくる。戦略クラスのテストはorderオブジェクト1つあれば書ける——これが良い設計の証。
StrategyパターンとRubyのブロック
Rubyでは、ブロックやlambdaを使って軽量なStrategyパターンを実装できる。シンプルなケースではこちらが便利だ。
# ブロックを使った軽量Strategy
class ShippingCalculator
def initialize(&strategy)
@strategy = strategy
end
def calculate(order)
@strategy.call(order)
end
end
# 使う側
premium_calculator = ShippingCalculator.new do |order|
order.total_price > 10_000 ? 0 : 300
end
regular_calculator = ShippingCalculator.new do |order|
order.total_price > 5_000 ? 500 : 800
end
# 簡単に切り替えられる
calculator = premium_calculator
puts calculator.calculate(order)クラスベースのStrategyとブロックベースのStrategyの使い分け:
| クラスベース | ブロックベース |
|---|---|
| 戦略が複雑でステートを持つ | 戦略が単純な1関数 |
| 戦略の数が多い | 戦略の数が少ない |
| テストを充実させたい | 素早くプロトタイプしたい |
| チームで共有する | 局所的に使う |
Railsエコシステムでの実際の使われ方
Railsエコシステムでは、Strategyパターンはあちこちで使われている。
Devise の認証戦略
# config/initializers/devise.rb
Devise.setup do |config|
# 認証戦略を追加できる(DatabaseAuthenticatable、OmniauthAuthenticatable など)
# それぞれが独立した戦略クラスとして実装されている
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
end
endDeviseの認証は典型的なStrategyパターンだ。「パスワード認証」「OmniAuth認証」「二要素認証」がそれぞれ独立した戦略として実装されており、設定によって戦略を差し替えられる。
ActiveStorage のストレージバックエンド
# config/storage.yml
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.aws[:access_key_id] %>
bucket: my-app-production
google:
service: GCS
project: my-gcp-project
bucket: my-app-production# 環境によってバックエンドを切り替える
# config/environments/production.rb
config.active_storage.service = :amazon
# config/environments/development.rb
config.active_storage.service = :localservice: Disk、service: S3、service: GCS がそれぞれ異なる戦略だ。アプリケーションコードは ActiveStorage::Blob を通じて同じインターフェースで操作できる。S3からGCSに移行しても、アプリケーションコードは変わらない。
カスタム検索の実装例
実務でよく使う検索機能にもStrategyパターンが有効だ。
# app/services/search_strategies/base_strategy.rb
module SearchStrategies
class BaseStrategy
def search(query, scope)
raise NotImplementedError
end
end
end
# app/services/search_strategies/full_text_strategy.rb
module SearchStrategies
class FullTextStrategy < BaseStrategy
def search(query, scope)
scope.where("to_tsvector('japanese', name) @@ plainto_tsquery(?)", query)
end
end
end
# app/services/search_strategies/like_strategy.rb
module SearchStrategies
class LikeStrategy < BaseStrategy
def search(query, scope)
scope.where("name LIKE ?", "%#{query}%")
end
end
end
# 環境やDBに応じて戦略を切り替え
class ProductSearcher
def initialize
@strategy = use_full_text_search? ? SearchStrategies::FullTextStrategy.new
: SearchStrategies::LikeStrategy.new
end
def search(query)
@strategy.search(query, Product.active)
end
private
def use_full_text_search?
ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
end
endAWSインフラでのStrategy的発想
AWSでも「戦略の切り替え」は至る所にある。
ALBのターゲットグループはStrategyパターンそのものだ。パスやヘッダーに基づいて、リクエストを処理する「戦略」を切り替える。/api/v1/* はECSに、/webhooks/* はLambdaに、/admin/* は別のECSクラスターに——という具合だ。アプリケーションは自分がどこにルーティングされるかを知らない。
AWS Step Functions の選択状態(Choice State) も同じだ。
{
"Type": "Choice",
"Choices": [
{
"Variable": "$.payment_method",
"StringEquals": "credit_card",
"Next": "ProcessCreditCard"
},
{
"Variable": "$.payment_method",
"StringEquals": "convenience_store",
"Next": "ProcessConvenienceStore"
}
],
"Default": "ProcessBankTransfer"
}ワークフローの「どの処理をするか」という戦略が、JSONで定義された選択ルールで切り替わる。
また、AWS Config Rules でコンプライアンスチェックの戦略を切り替えたり(マネージドルールとカスタムルール)、CloudWatch Alarms のアクション で通知戦略を変えたりすることも、同じ考え方だ。
WARNING
StrategyパターンはStrategyクラスが増えすぎると管理が大変になることがある。5〜6つ程度なら問題ないが、20以上になってくると「戦略の一覧」が把握しにくくなる。その場合は、戦略をDBで管理したり、プラグイン形式で読み込んだりする設計を検討する。
ケンタの気づき
「なるほど!戦略パターンって、『変わる部分を別のクラスに切り出す』ってことですね。コントローラは変わらなくて、戦略クラスだけが変わる。」
山田さんは頷いた。「そう。Strategyパターンの本質は変化の速度が異なるものを分離することだ。計算ロジック(よく変わる)とコントローラ(あまり変わらない)を分離した。料理の例えで言えば、レシピ(戦略)はシェフが変えるけど、注文を取る接客係(コントローラ)は変わらない。」
「設計の原則で言うと?」
「単一責任原則と開放/閉鎖原則だね。コントローラは1つの責任だけ持つ。新しい会員種別を追加するときに既存コードを修正しない。両方同時に満たせる。」
「テストもずっと書きやすくなりました。各戦略クラスのテストは数行で書けます。」
「テストが書きやすいということは、責務が明確だということだ。テストの書きやすさは、設計の良さのバロメーターだよ。」
ケンタはノートにメモした。
Strategyパターン = 変わる部分をクラスに抽出して、外から差し込む。コンテキストは「何をするか」を知っていて、「どうやるか」は知らなくていい。テストが書きやすくなれば、設計の方向が正しい。
INFO
この章のまとめ
- Strategyパターンはアルゴリズムをカプセル化し、交換可能にする
- 「変わる部分」(計算ロジック)と「変わらない部分」(コントローラ)を分離するのが核心
- コンテキストはインターフェースだけを知り、具体的な実装を知らない
- 開放/閉鎖原則:新機能は既存コードを修正せずに追加できる
- テストが戦略ごとに独立するため、カバレッジが上がりやすく速くなる
- RubyではブロックやlambdaでStrategyを軽量に実装することもできる
- ActiveStorage、Deviseなどのライブラリが実際にStrategyパターンを使っている
- AWSのALBルーティングやStep Functionsも同じ考え方で設計されている