💭

【PHP】単一責任の原則について SRP

に公開

はじめに

単一責任の原則は、モジュールはたったひとつのアクターに対して責務を負うべき[1]とする原則です。つまり、同じタイミング、同じ理由で変更されるものはひとつにまとめて、違うタイミング、違う理由で変更されるものは別々に分ける[1:1]というような事を定めています。

ここでは、あるシステムの生年月日の値オブジェクトを例に挙げて、この原則について解説します。まずこの原則の違反例から見ていきます。

単一責任の原則の違反例

あるシステムの生年月日の値オブジェクトを下記に示します。

<?php

/**
 * 生年月日
 */
class BirthDate
{
    private DateTimeImmutable $value;

    private const MAX_AGE = 150;

    private const RESTRICTED_AGE = 20;

    private const WEEK = [
        '日', '月', '火', '水', '木', '金', '土'
    ];

    public function __construct(DateTimeImmutable $birthDate)
    {
        $currentDate = new DateTimeImmutable();
        if ($birthDate > $currentDate) {
            throw new InvalidArgumentException('生年月日は未来の日付を指定できません。value: ' . $birthDate->format('Y-m-d'));
        }

        if ($this->getAge($birthDate) > self::MAX_AGE) {
            throw new InvalidArgumentException('生年月日が最大値を超えています。value: ' . $birthDate->format('Y-m-d'));
        }

        $this->value = $birthDate;
    }

    /**
     * 一部の機能の利用が制限されているかどうかを判定する
     */
    public function isRestrictedUse(): bool
    {
        return $this->getAge($this->value) < self::RESTRICTED_AGE;
    }

    /**
     * 生年月日を画面用にフォーマットする
     */
    public function formatValue(): string
    {
        return $this->value->format('Y年m月d日') . '(' . self::WEEK[$this->value->format('w')] . ')';
    }

    private function getAge(DateTimeImmutable $birthDate): int
    {
        $today = new DateTimeImmutable();
        $diff = $today->diff($birthDate);
        return $diff->y;
    }
}

このシステムには下記の仕様があるとします。

  • 20歳未満の方は一部の機能の利用を制限させる
  • 生年月日を画面に表示する際は、「2000年11月23日(木)」のような形式で表示させる

ここで、「20歳未満の方は一部の機能の利用を制限させる」仕様のアクターはビジネス責任者だとします。また、「生年月日の表示用フォーマット」の仕様のアクターはUI/UXデザイナーだとします。

どこが違反しているのか?

上記の値オブジェクトは、一見すると凝集度が高く良さそうですが、過剰な責務を負ってしまっています。「20歳未満の利用制限」と「生年月日の表示用フォーマット」が一つのクラスの中に定義されてしまっているからです。

「20歳未満の利用制限」はビジネスルールで、「生年月日の表示用フォーマット」は表示に関するルールです。それぞれのルールで変更を要求するアクターが違うので、変更の頻度や理由が全く異なります。
例えば今のままだと、表示のフォーマットを「2000年11月23日(木)」から「2000-11-23【木】」に変更したい場合に、表示に関するルールとは全く関係ない「20歳未満の利用制限」のビジネスルールにも少なからず変更の影響が出てしまいます。少なくても、ビジネスルールの方に全く変更の影響が出ていないと言い切れなくなってしまいます。

また、表示に関するルールはビジネスルールと比べて変更頻度が高いです。値オブジェクトは、システムの依存関係の最上位にあるので、値オブジェクトを変更したときの影響範囲は広くなります。そのため、変更頻度が高い表示に関するルールを値オブジェクトの中に定義してしまうと、不必要にシステムの変更コストを増大させてしまいます。

もし、アクターごとに開発チームが分かれていたらどうなるでしょうか?今回の場合だと、「ビジネス責任者」と「UI/UXデザイナー」でチームが分かれていた場合、それぞれのチームの担当者がBirthDateクラスの変更を取り合うことになってしまいます。そうなってしまうと、変更内容がコンフリクトしてしまったり、リリースのタイミングを調整したりする必要があるため、余計に機能のリリースが遅くなってしまいます。

このように、上記の値オブジェクトが「ビジネスルール」と「表示に関するルール」という2つの異なる責務を持ってしまっていることによって、変更コストが不必要に高く変更容易性が低いです。
「ビジネスルール」と「表示に関するルール」は、それぞれアクターが異なります。つまり、異なる変更の頻度や理由を持っているので、それぞれのルールを独立して変更できるようにするべきです。

単一責任の原則の適用例

先ほどの値オブジェクトから「表示に関するルール」を切り離して、その責務を専門に担うBirthDateFormatterクラスを新しく作りました。

<?php

/**
 * 生年月日
 */
class BirthDate
{
    readonly DateTimeImmutable $value;

    private const MAX_AGE = 150;

    private const RESTRICTED_AGE = 20;

    public function __construct(DateTimeImmutable $birthDate)
    {
        $currentDate = new DateTimeImmutable();
        if ($birthDate > $currentDate) {
            throw new InvalidArgumentException('生年月日は未来の日付を指定できません。value: ' . $birthDate->format('Y-m-d'));
        }

        if ($this->getAge($birthDate) > self::MAX_AGE) {
            throw new InvalidArgumentException('生年月日が最大値を超えています。value: ' . $birthDate->format('Y-m-d'));
        }

        $this->value = $birthDate;
    }

    /**
     * 一部の機能の利用が制限されているかどうかを判定する
     */
    public function isRestrictedUse(): bool
    {
        return $this->getAge($this->value) < self::RESTRICTED_AGE;
    }

    private function getAge(DateTimeImmutable $birthDate): int
    {
        $today = new DateTimeImmutable();
        $diff = $today->diff($birthDate);
        return $diff->y;
    }
}
<?php

class BirthDateFormatter
{
    private const WEEK = [
        '日', '月', '火', '水', '木', '金', '土'
    ];

    public function __construct(private DateTimeImmutable $birthDate) {}

    /**
     * 生年月日を画面用にフォーマットする
     */
    public function formatValue(): string
    {
        return $this->birthDate->format('Y年m月d日') . '(' . self::WEEK[$this->birthDate->format('w')] . ')';
    }
}

ビジネスルールはBirthDateクラスに、表示に関するルールはBirthDateFormatterクラスにそれぞれ分割したことによって、それぞれのルールを完全に独立して変更できるようになりました。
このように、変更の頻度や理由が異なるものはそれぞれを独立させることによって、変更の影響範囲を小さくでき変更容易性を高めることができます。この事を定めているのが単一責任の原則です。

まとめ

ひとつのモジュールはひとつのアクターに対して責務を負うべきとするのが単一責任の原則です。ひとつのモジュールが複数のアクターに対応してしまっているという事は、複数の変更の理由がそのモジュールにはあるということです。こうなってしまうと、不必要に変更の影響範囲が広くなってしまったり、担当者間でソースコードの変更を取り合ったりしてしまいます。
そうならないために、設計の際にアクターを考慮して、変更の頻度や理由が異なるものはそれぞれを独立させることでシステムの変更容易性を担保していくことは重要です。

脚注
  1. Robert C. Martin他, Clean Architecture, 2018年7月27日, 株式会社ドワンゴ, P81 P120 ↩︎ ↩︎

Discussion