【Ruby】オブジェクト指向設計の基本原則(Ruby流のSOLID入門)

Ruby

こんにちは、かつコーチです。

クラスやメソッドの書き方には慣れてきたけれど、規模が大きくなるとコードが読みづらくなる。

そんな悩みを抱えている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

「更新日時を記録する」という横断的な関心事をモジュールに切り出すことで、InvoiceOrderはそれぞれ本来の責務に集中できます。

依存性逆転原則を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の全体像を解説していきます。

タグ: #Ruby #上級者向け #設計

タイトルとURLをコピーしました