Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsで学ぶSOLID(1): 単一責任の原則(翻訳)

追記: 訳文修正いたしました(2018/03/28)。

概要

原著者の許諾を得て翻訳・公開いたします。

翻訳記事の相互リンクは今後更新いたします。

Railsで学ぶSOLID(1): 単一責任の原則(翻訳)

「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。

それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを高めることを目的としています。

  1. 単一責任の原則SRP: Single responsibility principle)(本記事)
  2. オープン/クローズの原則OCP: Open/closed principle)
  3. リスコフの置換原則LSP: Liskov Substitution Principle)
  4. インターフェイス分離の原則ISP: Interface Segregation Principle)
  5. 依存関係逆転の原則DIP: Dependency Inversion Principle)

今回は、最初の原則である「単一責任の原則」について詳しく見ていくことにします。

単一責任の原則(SRP)

クラス(オブジェクト)が担う責任は1つに限定すべきである(かつその責務は完全にカプセル化されるべきである)。

あるいは

クラスを変更するときの理由付けは、1つしかあってはならない。

これをもっと易しく言い換えれば「1つのクラスは1つの仕事しかしない」ということです。

しかし、クラスの責務というものはどうやって決まるのでしょうか?「変更する理由」というのはどのようにして決まる決めるものなのでしょうか?この問いに答えるのは難しく、容易ではありません。

次の文面について考えてみましょう。「クラスが更新される(書き換わる)理由は1つだけにとどまるべきである(ここで言う書き換えの対象はファイルのコードではなく、オブジェクトのメモリ上のステートを指すオブジェクトのメモリ上のステートではなくファイルのコードを指す)」。本記事ではこの理由を追求してみることにします。

訳注: 上記修正しました。

: ユーザーを1人作成したいとします。最初に必要なのはデータのバリデーションです。この機能をService Objectで作成してみましょう。おそらく次のようなクラスになるでしょう。

class UserCreateService
  def initialize(params)
    @params = params
  end

  def call
    return false unless valid?
    process_user_data
  end

  private

  def valid?
    # 複雑なバリデーションロジックを書く
    ...
  end

  def process_user_data
    ...
  end
end

このクラスはどんなことをするでしょうか?このクラスは「ユーザー入力のバリデーション」を行い、続いてそれを「処理」(おそらくユーザーをデータベースに保存)しています。「バリデーション」と「処理」という2つの操作はいかにも別物感があり、どうやら2つの異なる責務のようです。しかし上のやり方を修正または改良する前に、なぜ上のようなやり方が良くないのかを明らかにしなければなりません。

何より注目したいのは、2つの処理が強く結合していることです。この2つの処理の一方を別の場所で再利用するのは簡単ではありません。このようなクラスのメンテはつらいものになり、ミスを誘発する可能性が増えてしまいます。そしてテストも面倒になってしまいます(カバーしなければならないケースが増えてしまうため)。

そういうことであれば、バリデーションがらみのコードを別のクラスに切り出すのはいい考えかもしれません。とりあえずやってみましょう。

class UserClassService
  def initialize(params, validator: UserValidator)
    @params = params
    @validator = validator
  end

  def call
    return false unless validator.new(params).validate
    process_user_data
  end

  private

  attr_reader :params, :validator

  def process_user_data
    ...
  end
end
class UserValidator
  def initialize(params)
    @params = params
  end

  def validate
     ... #=> true/false
   end
end

クラスが2つになり、それぞれのクラスが担当する責任は1つだけになっています。最初のクラスは「データの処理」、次のクラスは「データのバリデーション」です。2つのクラスの結合はもう強くありません。バリデーションルールを変更しなければならなくなったとしても、UserValidationクラスだけを変更すれば済みます。同様に、バリデーション後の操作を変更しなければならなくなったとしても、UserCreateServiceクラスに手を付ける必要はもうありません。

しかも、それぞれのクラスを単独で再利用できるようになっています。UserValidationクラスを他のコンテキストで使うことも、UserCreateServiceを別のバリデーションクラスで使うこともできます(ここでは「依存性の注入: dependency injection」を使いました)。

他にもメリットはあるのでしょうか?もちろんです!コードには必ずテストも書くものです。今後はバリデーションと処理サービスのテストを個別に(分離した形で)書き、UserCreateServiceのテストではUserValidatorのレスポンスをモック化することになります。

だがしかし...

ドメイン駆動開発(DDD)という言葉はおそらくご存知かと思います。DDDという概念は、「情報エキスパート」(information expert)や「厚いドメインモデル」(薄いドメインモデルの対概念)といった用語に関連します。

訳注: 既存訳が見当たらないため、ここではrich domain modelを「厚いドメインモデル」、anemic domain modelを「薄いドメインモデル」と中立に訳出しました。

ここで「厚いドメインモデル」について少し考えてみましょう。この概念では、あらゆる振る舞いをそのモデルの「特定のモデル」に強く結合させておくことが前提になっています。そこではデータとそれを操作するロジックが同じ場所に置かれます。オブジェクト指向の話をしているので、これは自明に思えます。

次の例をちょっとご覧ください。

class User < ActiveRecord::Base
  validates :email, :first_name, :last_name, :presence: true

  def notify(notification)
    ...
  end

  def softDelete
    deleted_at = Time.zone.now
  end
end

なるほど、バリデーション、ユーザーへの通知ロジック、ソフトデリートのロジックが同居していて、責務を抱えすぎている趣があります。さてここで問題です。「このコードはSRPに違反しているでしょうか?」

私の回答は決まっています。「いいえ、何も違反していません

その理由は、ドメインモデルの責務が「自身のデータと一貫性」ただそれだけに限定されているからです。

私にとって、これは常にコンテキストによって決まるものです

「おいおい、ついさっき『1つのクラスに2つの操作があるのはダメダメ』って書いたばっかりじゃないの?」とツッコまれるかもしれません。しかし、SRPに違反しているかどうかはやはり「すべてコンテキスト次第」なのです。

Service Objectの目的は、単一の(アトミックな)操作を行うことであり、それ以外の何物でもありません。

まとめ

既にお気づきかと思いますが、この法則を理解するのは簡単なことではありません。主な理由としては、「変更の理由」の定義が難しくなってしまう場合があるのと、主張や解釈の余地が残されていることが挙げられます。実装そのものはとりたてて難しくはありません。

そこで私からは以下のアドバイスを記しておきたいと思います(このアドバイスはあらゆるルールに適用されます)。「ルールXを何が何でも満たそうと頑張らないこと」「そのルールでメリットを得られるように頑張ること」。私がこの原則を用いている理由はこれです。私は間違ってませんよね?

最後になりますが、この原則を正しく適用できれば、きっとコードはクリーンになり、読みやすくメンテしやすいものになるはずですし、理解や分析もやりやすくなるはずです。そうなればテストも同様に書きやすくなり、書いたテストもクリーンかつメンテしやすいものになります。

関連記事

Railsで重要なパターンpart 1: Service Object(翻訳)

Railsコードを改善する7つの素敵なGem(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。