こんにちは、かつコーチです。
クラスやメソッドの書き方には慣れてきたけれど、規模が大きくなるとコードが読みづらくなる。
そんな悩みを抱えているRubyエンジニアは多いのではないでしょうか。
この記事ではSOLID原則の中でも特に効果が大きい単一責任原則を中心に、Rubyらしい設計の考え方をBefore/After形式で解説します。
Ruby基礎編の最終回として、この後のRuby on Rails編につながる土台としてもお読みください。
基本の考え方・SOLID原則とRuby
SOLIDとRubyの相性
SOLIDはオブジェクト指向設計における5つの原則の頭文字を取ったものです。
単一責任原則(SRP)、開放閉鎖原則(OCP)、リスコフの置換原則(LSP)、インターフェース分離原則(ISP)、依存性逆転原則(DIP)から成ります。
Rubyはダックタイピングとmix-inによって、これらの原則を比較的軽量に実現できる言語です。
静的型付け言語のように継承階層でインターフェースを厳密に定義しなくても、モジュールとメソッドの組み合わせで柔軟に責務を分離できます。
ただし自由度が高い分、規律を意識しないとすぐにクラスが肥大化するのもRubyの特徴です。
この記事では実務での発生頻度が高いSRPを軸に、他の原則との関連も交えて解説します。
単一責任原則(SRP)が最初の一歩になる理由
SRPは「クラスが変更される理由は1つであるべき」という原則です。
複数の関心事が1つのクラスに混在すると、片方の変更がもう片方に予期せぬ影響を及ぼすリスクが高まります。
Railsのapp/models配下でActiveRecordモデルが肥大化する、いわゆるFat Modelの問題も、根本的にはSRP違反であることがほとんどです。
まずSRPを徹底できれば、他の原則も自然と守りやすくなります。
よくあるつまずきポイント・Before/After比較
Fat Modelになった請求処理クラス
Before
class Invoice
attr_reader :amount, :customer_email
def initialize(amount, customer_email)
@amount = amount
@customer_email = customer_email
end
def calculate_tax
amount * 0.1
end
def total_with_tax
amount + calculate_tax
end
def send_notification
# メール送信ロジックがここに直接書かれている
Net::SMTP.start("smtp.example.com", 587) do |smtp|
smtp.send_message(
"Total: #{total_with_tax}",
"billing@example.com",
customer_email
)
end
end
def save_to_database
# DB保存ロジックがここに直接書かれている
DB.execute("INSERT INTO invoices (amount, email) VALUES (?, ?)", amount, customer_email)
end
end
このクラスは「税額計算」「メール送信」「DB保存」という3つの異なる責務を1つのクラスに抱え込んでいます。
私が実際に保守していたプロジェクトでも似た構造があり、SMTPサーバーの設定を1行変更しただけのつもりが、テストコード全体でInvoiceクラスのモックを作り直す羽目になりました。
税金計算のロジックをテストしたいだけなのに、メール送信やDB接続までモックしないとテストが書けない状態は、明らかにSRP違反のサインです。
After
class Invoice
attr_reader :amount, :customer_email
def initialize(amount, customer_email)
@amount = amount
@customer_email = customer_email
end
def calculate_tax
amount * 0.1
end
def total_with_tax
amount + calculate_tax
end
end
class InvoiceMailer
def self.notify(invoice)
Net::SMTP.start("smtp.example.com", 587) do |smtp|
smtp.send_message(
"Total: #{invoice.total_with_tax}",
"billing@example.com",
invoice.customer_email
)
end
end
end
class InvoiceRepository
def self.save(invoice)
DB.execute("INSERT INTO invoices (amount, email) VALUES (?, ?)", invoice.amount, invoice.customer_email)
end
end
Invoiceは金額計算のみに責務を絞り、通知はInvoiceMailer、永続化はInvoiceRepositoryに切り出しました。
こうすることで、税率のロジックだけを対象にした単体テストが、外部依存を一切モックせずに書けるようになります。
条件分岐で肥大化する通知処理
Before
class NotificationSender
def send(user, message, type)
if type == :email
Net::SMTP.start("smtp.example.com", 587) do |smtp|
smtp.send_message(message, "noreply@example.com", user.email)
end
elsif type == :sms
SmsClient.new.deliver(user.phone_number, message)
elsif type == :slack
SlackClient.new.post(user.slack_id, message)
end
end
end
通知手段が増えるたびに、このクラスのif分岐がどんどん伸びていく設計です。
これはSRP違反であると同時に、拡張のたびに既存コードを直接書き換える必要がある点でOCP(開放閉鎖原則)にも違反しています。
新しい通知手段(プッシュ通知など)を追加するたびにNotificationSender本体を修正するので、既存のテストまで巻き込んで壊れるリスクが常につきまといます。
After
class EmailNotifier
def send(user, message)
Net::SMTP.start("smtp.example.com", 587) do |smtp|
smtp.send_message(message, "noreply@example.com", user.email)
end
end
end
class SmsNotifier
def send(user, message)
SmsClient.new.deliver(user.phone_number, message)
end
end
class SlackNotifier
def send(user, message)
SlackClient.new.post(user.slack_id, message)
end
end
class NotificationSender
def initialize(notifier)
@notifier = notifier
end
def send(user, message)
@notifier.send(user, message)
end
end
NotificationSender.new(EmailNotifier.new).send(user, "予約が確定しました")
各通知手段をそれぞれ独立したクラスに分離し、NotificationSenderは渡された通知手段(notifier)に処理を委譲するだけにしました。
Rubyはダックタイピングによって、sendメソッドさえ実装していればどのクラスでもnotifierとして渡せます。
新しい通知手段を追加したいときは、新しいクラスを1つ書き足すだけで済み、既存のクラスには一切手を入れずに拡張できます。
Rubyらしい設計のための一歩先の視点
mix-inで責務を横断的に共有する
継承ではなくモジュールのmix-inを使うと、複数のクラスに共通の振る舞いを持たせつつ、単一継承の制約を回避できます。
module Timestampable
def touch
@updated_at = Time.now
end
end
class Invoice
include Timestampable
end
class Order
include Timestampable
end
「更新日時を記録する」という横断的な関心事をモジュールに切り出すことで、InvoiceとOrderはそれぞれ本来の責務に集中できます。
依存性逆転原則をRubyの柔軟性で実現する
依存性逆転原則(DIP)は「上位のモジュールは下位の実装ではなく抽象に依存すべき」という原則です。
Rubyには明示的なインターフェース構文はありませんが、先ほどのNotificationSenderの例のように、コンストラクタで依存オブジェクトを注入する設計にするだけで、実質的にDIPを満たせます。
テスト時には本物のEmailNotifierの代わりにダミーのnotifierを注入すれば、外部通信を一切発生させずにテストが書けます。
まとめ
この記事のポイント
- SOLIDの中でもSRPを最初に徹底すると、他の原則も自然に守りやすくなる
- Fat Modelの多くは、計算・通知・永続化などの責務が1クラスに混在した結果である
- 条件分岐が伸び続けるクラスは、SRPとOCPの両方に違反しているサインである
- Rubyのダックタイピングとmix-inを使えば、静的型付け言語より軽量にSOLIDを実現できる
- コンストラクタでの依存オブジェクト注入は、Rubyの柔軟性を活かしたDIPの実践方法である
次に読むべき記事
ここまでRuby基礎編の20記事、お疲れさまでした。
言語の基本文法から設計原則まで身につけたら、次はいよいよRuby on Rails編に進みましょう。
「Ruby on Railsとは?」(rails-toha)から、フレームワークとしてのRailsの全体像を解説していきます。
