追記: 訳文修正いたしました(2018/03/28)。
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: SOLID Principles #1: Single Responsibility Principle | Netguru Blog on Ruby/Ruby on Rails
- 原文公開日: 2018/02/13
- 著者: Marcin Jakubowski
- サイト: netguru
翻訳記事の相互リンクは今後更新いたします。
Railsで学ぶSOLID(1): 単一責任の原則(翻訳)
「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。
それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを高めることを目的としています。
- 単一責任の原則(SRP: Single responsibility principle)(本記事)
- オープン/クローズの原則(OCP: Open/closed principle)
- リスコフの置換原則(LSP: Liskov Substitution Principle)
- インターフェイス分離の原則(ISP: Interface Segregation Principle)
- 依存関係逆転の原則(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を何が何でも満たそうと頑張らないこと」「そのルールでメリットを得られるように頑張ること」。私がこの原則を用いている理由はこれです。私は間違ってませんよね?
最後になりますが、この原則を正しく適用できれば、きっとコードはクリーンになり、読みやすくメンテしやすいものになるはずですし、理解や分析もやりやすくなるはずです。そうなればテストも同様に書きやすくなり、書いたテストもクリーンかつメンテしやすいものになります。