mybook

Stage 4: デザインパターン — GoFパターンの活用

パターンは「設計の語彙」

「デザインパターンって、23個全部覚えないといけないですか?」ヒロシは心配そうに聞いた。

マイは笑った。「全部覚える必要はない。パターンは共通の設計問題への名前のついた解法。名前を知ることで、チーム間のコミュニケーションが楽になる。」

「『ここはStrategyパターンで』と言えば、実装の詳細を説明しなくていい。設計の語彙を持つことで、複雑な問題をシンプルに表現できる。」

今日はGoFパターンの中から、Railsで特によく使われる3つを深く学ぶ。

Strategyパターン: アルゴリズムを交換可能にする

「まずStrategyから。これは一番使う頻度が高い。」

問題: 割引計算のロジックが増えるたびにif文が増えていく。

# パターンを知らないと書いてしまうコード
class PriceCalculator
  def calculate(order, discount_type)
    base_price = order.items.sum(&:price)
 
    case discount_type
    when :none       then base_price
    when :member     then base_price * 0.9
    when :vip        then base_price * 0.8
    when :bulk       then base_price > 50_000 ? base_price * 0.7 : base_price
    when :seasonal   then base_price - 1_000
    # 新しい割引のたびにここを修正しなければならない
    end
  end
end

Strategyパターンで解決:

# 各割引戦略をクラスに切り出す
module DiscountStrategy
  class None
    def apply(price)
      price
    end
  end
 
  class Member
    def apply(price)
      price * 0.9
    end
  end
 
  class Vip
    def apply(price)
      price * 0.8
    end
  end
 
  class Bulk
    THRESHOLD = 50_000
    DISCOUNT_RATE = 0.7
 
    def apply(price)
      price > THRESHOLD ? price * DISCOUNT_RATE : price
    end
  end
 
  class Seasonal
    def initialize(discount_amount)
      @discount_amount = discount_amount
    end
 
    def apply(price)
      [price - @discount_amount, 0].max
    end
  end
end
 
# PriceCalculatorはどの戦略かを知らなくていい
class PriceCalculator
  def initialize(discount_strategy = DiscountStrategy::None.new)
    @discount_strategy = discount_strategy
  end
 
  def calculate(order)
    base_price = order.items.sum(&:price)
    @discount_strategy.apply(base_price)
  end
end
 
# 使い方
calculator = PriceCalculator.new(DiscountStrategy::Vip.new)
calculator.calculate(order)
 
# テストも簡単
RSpec.describe PriceCalculator do
  it "VIP割引を適用する" do
    strategy = DiscountStrategy::Vip.new
    calc = PriceCalculator.new(strategy)
    order = build(:order, items: [build(:item, price: 10_000)])
    expect(calc.calculate(order)).to eq(8_000)
  end
end
Loading diagram...

INFO

RailsでのStrategyパターンの活用場所

  • 支払い方法(Stripe/PayPal/銀行振込)
  • 通知チャンネル(Email/SMS/Push)
  • エクスポート形式(PDF/CSV/Excel)
  • 検索アルゴリズム(全文検索/タグ検索/地理検索)

Observerパターン: イベントと反応を分離する

「次はObserver。Railsのcallbackと深く関係している。」

問題: ユーザー登録時に「メール送信・Slack通知・ポイント付与」など複数の処理が必要。全部Userモデルに書くとカオスになる。

# 悪い例: モデルに副作用を詰め込む
class User < ApplicationRecord
  after_create :send_welcome_email, :notify_slack, :grant_signup_points
 
  private
 
  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end
 
  def notify_slack
    SlackNotifier.notify("#signups", "新規登録: #{email}")
  end
 
  def grant_signup_points
    points.create!(amount: 100, reason: "signup")
  end
end

「テストするたびにSlackに通知が飛ぶ。ユーザー登録とは無関係な処理がモデルに縛り付けられている。」

Observerパターン(ActiveSupport::Notificationsで実装):

# app/models/user.rb — モデルはシンプルに
class User < ApplicationRecord
  after_create :publish_registered_event
 
  private
 
  def publish_registered_event
    ActiveSupport::Notifications.instrument("user.registered", user: self)
  end
end
 
# app/observers/user_welcome_observer.rb
class UserWelcomeObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("user.registered") do |_name, _start, _finish, _id, payload|
      user = payload[:user]
      UserMailer.welcome(user).deliver_later
    end
  end
end
 
# app/observers/user_slack_observer.rb
class UserSlackObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("user.registered") do |_name, _start, _finish, _id, payload|
      user = payload[:user]
      SlackNotifier.notify("#signups", "新規登録: #{user.email}")
    end
  end
end
 
# app/observers/user_points_observer.rb
class UserPointsObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("user.registered") do |_name, _start, _finish, _id, payload|
      user = payload[:user]
      user.points.create!(amount: 100, reason: "signup")
    end
  end
end
 
# config/initializers/observers.rb
UserWelcomeObserver.subscribe!
UserSlackObserver.subscribe!
UserPointsObserver.subscribe!

「Userモデルはuser.registeredイベントを発火するだけ。何が起きるかは知らない。関心の分離ができた。」

Loading diagram...

WARNING

ActiveRecord Callbackの罠

Railsのafter_createは便利ですが、ビジネスロジックを詰め込むと「テストしにくいモデル」ができます。Observerパターンを使い、副作用をモデルから切り離しましょう。

Factoryパターン: オブジェクト生成を抽象化する

「最後はFactory。オブジェクトの生成ロジックが複雑になったときの解法。」

問題: 決済プロバイダーを設定によって切り替えたい。

# 悪い例: 使う側が生成ロジックを知っている
class PaymentController < ApplicationController
  def create
    gateway = if Rails.env.production?
      if ENV["PAYMENT_PROVIDER"] == "stripe"
        StripeGateway.new(api_key: ENV["STRIPE_API_KEY"])
      elsif ENV["PAYMENT_PROVIDER"] == "paypal"
        PaypalGateway.new(client_id: ENV["PAYPAL_CLIENT_ID"], secret: ENV["PAYPAL_SECRET"])
      end
    else
      FakeGateway.new
    end
 
    gateway.charge(params[:amount], params[:token])
  end
end

Factoryパターンで解決:

# app/factories/payment_gateway_factory.rb
class PaymentGatewayFactory
  GATEWAYS = {
    "stripe" => -> { StripeGateway.new(api_key: ENV.fetch("STRIPE_API_KEY")) },
    "paypal" => -> {
      PaypalGateway.new(
        client_id: ENV.fetch("PAYPAL_CLIENT_ID"),
        secret: ENV.fetch("PAYPAL_SECRET")
      )
    }
  }.freeze
 
  def self.create(provider = ENV.fetch("PAYMENT_PROVIDER", "stripe"))
    return FakeGateway.new unless Rails.env.production?
 
    factory = GATEWAYS[provider]
    raise ArgumentError, "未知のプロバイダー: #{provider}" unless factory
    factory.call
  end
end
 
# app/controllers/payment_controller.rb
class PaymentController < ApplicationController
  def create
    gateway = PaymentGatewayFactory.create
    result = gateway.charge(params[:amount], params[:token])
    render json: result
  end
end
 
# テストでも簡単にモック可能
RSpec.describe PaymentController do
  before do
    allow(PaymentGatewayFactory).to receive(:create).and_return(FakeGateway.new)
  end
end

Abstract Factory: 関連するオブジェクト群を生成

# 通知システム全体を環境に応じて切り替える
module NotificationFactory
  def self.create_suite(env = Rails.env)
    case env.to_s
    when "production"
      {
        email: SesEmailNotifier.new,
        sms:   TwilioSmsNotifier.new,
        push:  FcmPushNotifier.new
      }
    when "test"
      {
        email: FakeEmailNotifier.new,
        sms:   FakeSmsNotifier.new,
        push:  FakePushNotifier.new
      }
    else
      {
        email: LogEmailNotifier.new,
        sms:   LogSmsNotifier.new,
        push:  LogPushNotifier.new
      }
    end
  end
end
 
# AWS SES を使ったEmailNotifier
class SesEmailNotifier
  def initialize
    @client = Aws::SES::Client.new(region: "ap-northeast-1")
  end
 
  def notify(user, message)
    @client.send_email(
      destination: { to_addresses: [user.email] },
      message: {
        subject: { data: "お知らせ" },
        body: { text: { data: message } }
      },
      source: "noreply@example.com"
    )
  end
end

パターンの使いどころ

「パターンを使いすぎるのも問題。」マイが注意した。

「YAGNIという原則がある。You Aren't Gonna Need It。今必要じゃないものは作らない。」

Loading diagram...
パターン使うとき
Strategyアルゴリズムを実行時に切り替えたい
Observer一つのイベントに複数の反応が必要
Factoryオブジェクト生成ロジックが複雑または環境依存
Decorator既存クラスに機能を動的に追加したい
Template Method処理の骨格を固定し、詳細をサブクラスに委ねたい

AWSでのパターン活用

# CloudFormationでのFactory的な設計
# 環境(dev/staging/prod)に応じて構成を切り替える
 
Parameters:
  Environment:
    Type: String
    AllowedValues: [dev, staging, prod]
 
Conditions:
  IsProduction: !Equals [!Ref Environment, prod]
 
Resources:
  Database:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: !If [IsProduction, db.r6g.xlarge, db.t3.micro]
      MultiAZ: !If [IsProduction, true, false]

Stage 4 のまとめ

ヒロシは今日学んだパターンを振り返った。

「パターンって、ゼロから考えなくていいんですね。先人が解決した問題への答えが、名前付きで提供されている。」

「そう。そして名前があることで、チームで話せる。『ここはObserverにしよう』と言えば、一発で意図が伝わる。」

パターン問題解決
Strategyアルゴリズムのif地獄戦略を交換可能なオブジェクトに
Observerモデルへの副作用詰め込みイベントと反応を分離
Factory複雑な生成ロジック生成の責務をFactoryに集約

「次はアーキテクチャ原則。クラス設計からコンポーネント設計へ、スケールを上げる。」

デザインパターンという語彙を手に入れたヒロシは、コードリーティングが楽しくなってきた気がした。