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)
「一つのクラスは、一つの理由でのみ変更される。」
# 各クラスが単一の責務を持つ
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
endD: 依存性逆転の原則(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原則を組み合わせる
実際の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を書き直しながら、コードが変わっていく感覚を味わっていた。