mybook

Stage 3: SOLID原則 — オブジェクト指向設計の5原則

設計の「痛み」から生まれた原則

「マイさん、SOLID原則って聞いたことはあるんですが、正直よくわからなくて……」

「それは正常。抽象的な定義を読んでも身につかない。コードが壊れる痛みを体験してから理解するものだから。」

マイはヒロシのコードを見た。「まず、なぜ設計原則が必要かを体感しよう。」

# この Notification クラスが抱える問題
class Notification
  def send(user, type)
    if type == "email"
      smtp = Net::SMTP.start("smtp.gmail.com", 587) do |s|
        s.send_message(build_email(user), "from@example.com", user.email)
      end
    elsif type == "sms"
      Twilio::REST::Client.new.messages.create(
        from: ENV["TWILIO_NUMBER"],
        to: user.phone,
        body: "通知があります"
      )
    elsif type == "push"
      Apns2::Client.development.push(
        Apns2::Notification.new(user.device_token, { body: "通知" })
      )
    end
  end
end

「SMSの文面を変えたい。そのたびにこのクラスを開いて、関係ないメール送信ロジックのそばでコードを変更する。怖くない?」

「怖いです。他の部分を壊してしまいそう。」

「その恐怖こそがSOLIDが解決する問題だ。」

S: 単一責任の原則(Single Responsibility)

「一つのクラスは、一つの理由でのみ変更される。」

Loading diagram...
# 各クラスが単一の責務を持つ
class EmailNotifier
  def initialize(smtp_config = nil)
    @smtp_config = smtp_config || { host: "smtp.gmail.com", port: 587 }
  end
 
  def notify(user, message)
    Net::SMTP.start(@smtp_config[:host], @smtp_config[:port]) do |smtp|
      smtp.send_message(build_message(user, message), "noreply@example.com", user.email)
    end
  end
 
  private
 
  def build_message(user, message)
    "To: #{user.email}\nSubject: 通知\n\n#{message}"
  end
end
 
class SmsNotifier
  def notify(user, message)
    Twilio::REST::Client.new.messages.create(
      from: ENV["TWILIO_NUMBER"],
      to: user.phone,
      body: message
    )
  end
end
 
class PushNotifier
  def notify(user, message)
    Apns2::Client.development.push(
      Apns2::Notification.new(user.device_token, { body: message })
    )
  end
end

「メールの設定を変えたいときはEmailNotifierだけ触ればいい。影響範囲が明確になった。」

O: 開放閉鎖の原則(Open/Closed)

「拡張に対して開いていて、修正に対して閉じている。新しい機能を追加するとき、既存のコードを変更しない設計にする。」

# 悪い例: 新しい通知タイプを追加するたびにcase文を修正する
class NotificationService
  def send(user, type, message)
    case type
    when :email then EmailNotifier.new.notify(user, message)
    when :sms   then SmsNotifier.new.notify(user, message)
    # Slackを追加するにはここを変更しなければならない
    end
  end
end
 
# 良い例: 新しいNotifierを登録するだけ
class NotificationService
  def initialize
    @notifiers = {}
  end
 
  def register(type, notifier)
    @notifiers[type] = notifier
    self
  end
 
  def send(user, type, message)
    notifier = @notifiers.fetch(type) { raise ArgumentError, "未知の通知タイプ: #{type}" }
    notifier.notify(user, message)
  end
end
 
class SlackNotifier
  def notify(user, message)
    SlackClient.post(channel: user.slack_channel, text: message)
  end
end
 
# 追加はregisterするだけ。NotificationServiceは変更不要
service = NotificationService.new
  .register(:email, EmailNotifier.new)
  .register(:slack, SlackNotifier.new)

INFO

RailsでのOCP実践例

ActiveRecord の scope はOCPの好例です。新しい条件を追加するとき、既存のスコープを変更せず、新しいスコープを追加するだけで済みます。

scope :active, -> { where(status: :active) }
scope :premium, -> { where(tier: :premium) }
# 新しい条件を追加 — 既存コードに影響なし
scope :trial, -> { where(tier: :trial, trial_ends_at: Time.current..) }

L: リスコフの置換原則(Liskov Substitution)

「サブクラスは親クラスと置き換え可能でなければならない。」

# 悪い例: サブクラスが親クラスの契約を破る
class Rectangle
  attr_accessor :width, :height
 
  def area
    width * height
  end
end
 
class Square < Rectangle
  def width=(value)
    @width = value
    @height = value  # 正方形なので両方変える
  end
 
  def height=(value)
    @height = value
    @width = value   # ← これがRectangleの契約を破る!
  end
end
 
def resize(rectangle)
  rectangle.width = 10
  rectangle.height = 5
  rectangle.area  # Rectangleなら50、Squareなら25になってしまう
end
 
# 良い例: 階層を分ける
class Shape
  def area
    raise NotImplementedError
  end
end
 
class Rectangle < Shape
  def initialize(width, height)
    @width = width
    @height = height
  end
 
  def area
    @width * @height
  end
end
 
class Square < Shape
  def initialize(side)
    @side = side
  end
 
  def area
    @side ** 2
  end
end

「正方形は長方形の特殊ケースに見えるけど、動作の契約が違う。無理に継承させると、使う側が混乱する。」

I: インターフェイス分離の原則(Interface Segregation)

「クライアントは、自分が使わないメソッドに依存させてはならない。」

Rubyにはインターフェイスという言語機能はないが、モジュールやDuck Typingで表現できる。

# 悪い例: 巨大なインターフェイス
module Reportable
  def generate_pdf; end
  def generate_csv; end
  def generate_excel; end
  def send_email; end
  def archive; end
end
 
# PDF生成だけ必要なクラスが全メソッドを実装しなければならない
class SalesReport
  include Reportable
 
  def generate_pdf
    # 実装
  end
 
  def generate_csv
    raise NotImplementedError  # 不要なのに実装を強制される
  end
  # ...
end
 
# 良い例: 役割ごとにモジュールを分離
module PdfExportable
  def generate_pdf
    raise NotImplementedError, "#{self.class}はgenerate_pdfを実装してください"
  end
end
 
module CsvExportable
  def generate_csv
    raise NotImplementedError
  end
end
 
module Archivable
  def archive
    update!(archived_at: Time.current)
  end
end
 
# 必要なものだけinclude
class SalesReport
  include PdfExportable
  include Archivable
 
  def generate_pdf
    # PDFだけ実装すればいい
  end
end

D: 依存性逆転の原則(Dependency Inversion)

「上位モジュールは下位モジュールに依存してはならない。両方とも抽象に依存すべき。」

# 悪い例: 上位クラスが具体的な下位クラスに直接依存
class OrderProcessor
  def initialize
    @payment_gateway = StripePaymentGateway.new  # Stripeに直接依存!
    @logger = FileLogger.new("orders.log")       # FileLoggerに直接依存!
  end
 
  def process(order)
    @logger.log("Processing order #{order.id}")
    @payment_gateway.charge(order.total, order.user.card_token)
  end
end
 
# 良い例: 抽象(インターフェイス)に依存させる
class OrderProcessor
  def initialize(payment_gateway:, logger:)
    @payment_gateway = payment_gateway  # 外から注入
    @logger = logger
  end
 
  def process(order)
    @logger.log("Processing order #{order.id}")
    @payment_gateway.charge(order.total, order.user.card_token)
  end
end
 
# 任意の実装を差し込める
class StripePaymentGateway
  def charge(amount, token)
    Stripe::Charge.create(amount: amount, source: token)
  end
end
 
class PaypalPaymentGateway
  def charge(amount, token)
    PayPal::SDK::REST::Payment.create(...)
  end
end
 
# テスト用のFake実装
class FakePaymentGateway
  attr_reader :charges
 
  def initialize
    @charges = []
  end
 
  def charge(amount, token)
    @charges << { amount: amount, token: token }
    { id: "fake_charge_#{SecureRandom.hex(4)}", status: "succeeded" }
  end
end
 
# 本番環境
processor = OrderProcessor.new(
  payment_gateway: StripePaymentGateway.new,
  logger: Rails.logger
)
 
# テスト環境 — 本物のStripeを呼ばずに済む
fake_gateway = FakePaymentGateway.new
processor = OrderProcessor.new(
  payment_gateway: fake_gateway,
  logger: Logger.new(nil)
)
processor.process(order)
expect(fake_gateway.charges.count).to eq(1)

INFO

RailsでのDI: コンストラクタインジェクション

Railsでは依存性の注入にコンストラクタインジェクションがシンプルでよく使われます。より大規模なアプリでは dry-container gem も選択肢です。

5原則を組み合わせる

Loading diagram...

実際のRailsアプリでSOLIDを適用した例:

# app/services/order_notification_service.rb
class OrderNotificationService
  # D原則: 抽象(notify可能なオブジェクト)に依存
  def initialize(notifiers: [])
    @notifiers = notifiers
  end
 
  # S原則: 通知送信という単一の責務
  def notify_order_completed(order)
    @notifiers.each do |notifier|
      # L原則: どのnotifierも同じインターフェイスを持つ
      notifier.notify(order.user, "ご注文が完了しました: #{order.id}")
    end
  end
end
 
# config/initializers/services.rb
OrderNotificationService.new(
  notifiers: [
    EmailNotifier.new,
    SmsNotifier.new,
    PushNotifier.new
  ]
)

AWS環境でのSOLID

SOLID原則はアーキテクチャレベルにも適用できる。

# AWSでの実装例: SNS + SQS でOCP的な通知基盤
# 新しい通知チャンネルを追加してもSNS Topicの変更は不要
Resources:
  OrderNotificationTopic:
    Type: AWS::SNS::Topic
 
  EmailQueue:
    Type: AWS::SQS::Queue
  SmsQueue:
    Type: AWS::SQS::Queue
 
  EmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: sqs
      TopicArn: !Ref OrderNotificationTopic
      Endpoint: !GetAtt EmailQueue.Arn
 
  # 新しいチャンネルはSubscriptionを追加するだけ
  SlackSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: https
      TopicArn: !Ref OrderNotificationTopic
      Endpoint: https://hooks.slack.com/...

Stage 3 のまとめ

「SOLID原則は、変更への恐怖をなくすための知恵だ。」マイが言った。

原則キーワード効果
S: 単一責任変更の理由は一つ影響範囲が明確
O: 開放閉鎖拡張OK、修正NG既存コードが安全
L: リスコフ置換サブは親と置き換え可能予期せぬ動作なし
I: インターフェイス分離必要なものだけ依存不要な実装が消える
D: 依存性逆転抽象に依存するテストが容易になる

「次はデザインパターン。SOLIDを土台にした、再利用可能な設計の語彙を学ぼう。」

ヒロシはOrderProcessorを書き直しながら、コードが変わっていく感覚を味わっていた。