単一責任の原則
単一責任の原則とはコードの変更容易性を向上させ、ソフトウェアの保守性を高めるためのSOLID原則の1つである。
単一責任の原則とは「モジュール(クラス)を変更する理由はたった一つであるべき」ということを提唱している。
ここでいう “責務” は、変更理由(あるいは変更を要求するアクター) とほぼ同義だと捉えると理解しやすい。
責任が大きいクラスの場合どうなる?
クラスが大きな責任を持つとは、複数の関心事が同じクラスに押し込まれている状態を指す。
こうなると、アプリケーションの様々な変更がすべて同じクラスへの変更として集約され、そのクラスの変更頻度は増加していく。
結果として、変更の範囲が不明瞭になり、副作用も起きやすくなる。
振る舞いを少し変えるだけでも大きな修正が必要になり、開発工数を圧迫する。
一般的に fat になりやすいクラスは単一責任の原則に違反しているケースが多い。
例としてService(UseCase)でよくあるパターンを出す。
class UserService
{
public function store(array $input): User
{
$this->validate($input); // 入力検証
$user = $this->repository->store($input); // 永続化(作成)
$this->sendMail($user); // メール送信
return $user;
}
public function update(int $id, array $input): User
{
$this->validate($input); // 入力検証
$user = $this->repository->update($id, $input); // 永続化(更新)
$this->sendMail($user); // メール送信
return $user;
}
private function validate(array $input): void {}
private function sendMail(User $user): void {}
}
storeとupdateの中でvalidateとsendMailを呼び出している。
このUserServiceは、
- 入力検証
- 永続化(作成)
- 永続化(更新)
- メール送信
という複数の関心事を抱えてしまっている。
見える範囲だけで4つの変更理由が存在し単一責任の原則を破りやすい形になっている。
さらに、もし一般ユーザーと管理者ユーザーなど 複数のアクターが同じServiceを共有しているなら、アクターごとの仕様変更が追加の変更理由として乗ってくる。
この状態でクラスが育つと、条件分岐やメソッドが増殖し、可読性が落ち、変更の影響範囲も見えなくなる。
テストもユニットで切り出しにくくなり、結果的にインテグレーションテスト中心になる。
インテグレーションテスト中心になると、
- 失敗原因の特定が難しい
- 実行コストが高い
- 変更時のフィードバックが遅い
といった形で、保守性の低下に拍車がかかる。
責任が1つのクラスの場合どうなる?
逆に責任が分かれている場合、クラスごとにやることが絞られているため、変更の範囲が明瞭になる。
変更の副作用も責務を担当するクラスの中に閉じ込めやすく、影響を局所化できる。
つまり単一責任の原則を守ることは、
変更理由のスコープを限定し、他の関係ないクラスへの波及を減らすことである。
責務をどう分ける?
責務を分ける際の基本的な考え方はシンプルで、変更理由が異なるものを別のクラスにするだけである。
例えば、先ほどのUserServiceに対して変更理由を分解してみると
- 入力検証
- 永続化(これはすでにrepositoryで分割できている)
- メール送信
- ユースケース(作成/更新)
- アクター
あくまでコード例だが
入力検証、メール送信、永続化(これはすでにあった)を切り出す
class UserValidator {
public function validateForCreate(array $input) {}
public function validateForUpdate(array $input) {}
}
class UserMailer {
public function sendWelcomeMail(User $user) {}
public function sendProfileChangedMail(User $user) {}
}
// interfaceで定義して、具象クラスは別で定義していることとする
interface UserRepository {
public function store(array $input): User;
public function update(int $id, array $input): User;
}
ユースケース(service自体を分ける)
class CreateUserUseCase
{
public function __construct(
private UserValidator $validator,
private UserRepository $repository,
private UserMailer $mailer,
) {}
public function handle(array $input): User
{
$this->validator->validateForCreate($input);
$user = $this->repository->store($input);
$this->mailer->sendWelcomeMail($user);
return $user;
}
}
class UpdateUserUseCase
{
public function __construct(
private UserValidator $validator,
private UserRepository $repository,
private UserMailer $mailer,
) {}
public function handle(int $id, array $input): User
{
$this->validator->validateForUpdate($input);
$user = $this->repository->update($id, $input);
$this->mailer->sendProfileChangedMail($user);
return $user;
}
}
こうすることで、責務が分割され、変更理由が局所化される。
- メール仕様の変更 → Mailer のみ変更
- 入力チェックの仕様変更 → Validator のみ変更
- DB構造が変わる → Repository のみ変更
- 作成と更新の業務仕様が変わる → 各 UseCase のみ変更
変更に対して、他のクラスを巻き込まない設計となった。
また他のクラスでもメール送信や入力チェックなど使用する場合でもクラスを呼び出せば良くなっている。これはロジックがアプリケーションのなかで分散しにくいことを意味する。
さらにそれぞれのクラスが単一の責任のみのため、テストが分割してしやすくなった。
要はユニットテストが書けるようになった。
- Validator は Validator 単体でテスト可能
- Repository は DB テストに集中できる
- Mailer はメール部分だけモックして振る舞いテストできる
- UseCase は依存を全てモックしてロジックだけテストできる
インテグレーションに頼らず、小さいユニットテストで高速に品質担保できる。
また単一責任なクラスは条件分岐が少なくなり、メソッド数も限定される。
複数責任を持つクラスに対して、全体的なファイル数やコード数は増えるが、変更の影響範囲がわかりやすく、
クラス名が責務を表現するため、可読性も上がるのである。
(クラスやメソッドの命名が重要であると言うのはエンジニアなら当たり前のことであるが、コードの整理をしてより、これが引き立つのである。クラス、メソッドの振る舞いが命名に適切に反映されていれば、Service層では本の目次を読むように処理がなんとなく理解できるのである。)
アクターによる責務が残っているが、これは単純にServiceを分けてしまえばいい。
例えば一般ユーザーと有料課金ユーザーの場合は以下のように分ける。
// 一般ユーザー向け
class CreateGeneralUserUseCase
class UpdateGeneralUserUseCase
// 有料課金ユーザー向け
class CreatePaidUserUseCase
class UpdatePaidUserUseCase
この際に一般ユーザーと有料課金ユーザーで似たような処理となり、1つのクラスにしたい誘惑に駆られる。
実際に全く同じ処理であればその選択肢もあり得る。ただ少しでも違いがあるのであれば単一責任の原則にしたがって似たような処理でも勇気を持ってクラスを分けた方がいい。
まとめ
1つのクラスに複数の関心事が混ざると、
そのクラスは次々と異なる変更理由を背負い、肥大化し、
可読性・テスト容易性・保守性のすべてが急速に損なわれていく。
逆に、責務を適切に分割し、変更理由をそれぞれ別のクラスへ委ねることで、
- 変更が局所化される
- 副作用の発生が抑えられる
- クラスは理解しやすく可読性が上がる。
- テストも小さな単位で高速に行える。
結果としてシステム全体の保守性や変更容易性が向上することとなる。
SOLID原則のなで最も基本的で、実践しやすい原則であるため是非、実践してみてほしい。
もう一つ付け加えると、ここまで述べた内容と矛盾するようだが、
プロダクトのフェーズやビジネス要求のスピードによっては、原則をあえて破ることが正義になる場合もある。
原則はあくまで原則であり、常に正しいわけではない。
ただし、先人たちが苦悩の末に導いた原則は、プロダクトが成長し、運用が安定してくるフェーズになるほど必要になってくる。
初期フェーズでは多少の一体化や無理な実装が問題にならなくても、
成長フェーズでは確実に負債として表面化するケースが多い。
だからこそ、原則を知った上で、破る理由を持って行動することが大切であり、
チームやプロダクトの状況を見て、判断していくと良いと思う。