クラス設計の基本:責務分離編

クラス設計の基本:責務分離編

はじめに

前回の「クラス設計の基本:内部設計編」では、1つのクラスをどう作るかという内部設計の原則を扱いました。

クラス設計の基本:内部設計編

本記事ではその続きとして、クラスをどう分けるかという観点を扱います。設計の問題は1つのクラスの中だけでなく、クラスとクラスの関係にも現れます。責務が混在したクラスがなぜ壊れやすいのか、どう分割すればよいのか、そしてその良し悪しをどう測るかを解説します。

単一責任の原則(SRP):1つのクラスに1つの責務

責務が混在したクラスはなぜ壊れやすいのか?

機能が増えるにつれて、クラスが少しずつ肥大化していく経験は誰しもあるはずです。最初はシンプルだったUseServiceに、気づけばこんな処理が同居しているケースはよく見かけます。

// NG: 複数の責務が1つのクラスに混在している
class UserService
{
    public function register(array $data): User
    {
        // バリデーション
        if (empty($data['email'])) {
            throw new \InvalidArgumentException('メールアドレスは必須です');
        }

        // ユーザーの永続化
        $user = new User();
        $user->name  = $data['name'];
        $user->email = $data['email'];
        $user->save();

        // ウェルカムメールの送信
        Mail::to($user->email)->send(new WelcomeMail($user));

        // 管理者への通知
        Slack::send("新規ユーザーが登録されました: {$user->email}");

        return $user;
    }
}

一見まとまっているように見えますが、このクラスは以下の4つの異なる関心事を抱えています。

  • 入力値のバリデーション
  • ユーザーデータの永続化
  • ウェルカムメールの送信
  • 管理者へのSlack通知

これらが1つのクラスに同居している状態で、たとえば「管理者がユーザーを手動登録する機能」が追加されたとします。新しい担当者はUserServiceのregister()が肥大化しているのを見て、再利用が難しいと判断します。結果として似たようなバリデーションやメール送信のコードを別のクラスにコピーして実装します。さらに後日、「招待リンクからの登録フロー」が追加されたときも同じことが起きます。

// 通常登録
class UserService
{
    public function register(array $data): User
    {
        if (empty($data['email'])) { ... }  // バリデーション
        Mail::to(...)->send(new WelcomeMail($user));  // メール送信
    }
}

// 管理者による手動登録
class AdminUserService
{
    public function createUser(array $data): User
    {
        if (empty($data['email'])) { ... }  // 同じバリデーションがコピーされている
        Mail::to(...)->send(new WelcomeMail($user));  // 同じメール送信がコピーされている
    }
}

// 招待リンクからの登録
class InvitationService
{
    public function acceptInvitation(array $data): User
    {
        if (empty($data['email'])) { ... }  // またコピーされている
        Mail::to(...)->send(new WelcomeMail($user));  // またコピーされている
    }
}

この状態で「ウェルカムメールの件名を変えたい」という変更が入ったとき、何が起きるでしょうか。修正すべき箇所をUserServiceAdminUserServiceInvitationServiceと探して回らなければなりません。しかも「他にも同じコードを書いた箇所がないか」という不安が常につきまといます。1箇所でも修正が漏れれば、同じシステムの中で挙動が食い違う状態が生まれます。

「変更理由が1つ」という基準

単一責任の原則(Single Responsibility Principle)とは、1つのクラスには1つの責務だけを持たせるべきという考え方です。

ここでいう責務とは「そのクラスが変化する理由」のことです。先ほどのUserServiceを例にすると、変更が起きる理由として以下が考えられます。

  • バリデーションルールが変わった→入力値チェックの仕様変更
  • メールの文面が変わった→マーケティングの要求
  • 通知先がSlackからTeamsに変わった→インフラ・運用の要求
  • DBのスキーマが変わった→データ層の変更

これだけの変更理由が1つのクラスに混在しています。どれか1つの変更が、関係のない処理に影響を与える可能性があります。

責務を分離する

それぞれの関心事を独立したクラスに切り出すと、以下のような構成になります。

// ユーザー登録のドメインロジックに専念するクラス
class UserRegistrar
{
    public function register(array $data): User
    {
        if (empty($data['email'])) {
            throw new \InvalidArgumentException('メールアドレスは必須です');
        }

        $user = new User();
        $user->name  = $data['name'];
        $user->email = $data['email'];
        $user->save();

        return $user;
    }
}

// メール送信に専念するクラス
class WelcomeMailer
{
    public function send(User $user): void
    {
        Mail::to($user->email)->send(new WelcomeMail($user));
    }
}

// 管理者通知に専念するクラス
class AdminNotifier
{
    public function notifyUserRegistered(User $user): void
    {
        Slack::send("新規ユーザーが登録されました: {$user->email}");
    }
}

// それぞれを組み合わせる呼び出し元
class UserService
{
    public function __construct(
        private readonly UserRegistrar  $registrar,
        private readonly WelcomeMailer  $mailer,
        private readonly AdminNotifier  $notifier,
    ) {}

    public function register(array $data): User
    {
        $user = $this->registrar->register($data);
        $this->mailer->send($user);
        $this->notifier->notifyUserRegistered($user);

        return $user;
    }
}

それぞれのクラスの変更理由は1つになりました。

  • UserRegister→ユーザー登録のロジックが変わった時
  • WelcomeMailer→メール送信の仕様が変わった時
  • AdminNotifier→通知先や通知内容が変わった時

このように責務ごとにクラスを分離することで、変更が入っても担当のクラスを修正するだけで済みます。たとえばSlack通知をTeamsに切り替えたければAdminNotifierだけを変更すればよく、他のクラスには影響しません。

「このクラスは何者か」を一文で説明できるか

責務が適切に分離できているかを確認するシンプルな方法があります。そのクラスが何をするものか、「~を~するクラス」という一文で説明できるかを試してみてください。

クラス 一文での説明
UserRegister ユーザーの入力データを検証し、永続化するクラス
WelcomeMailer 新規ユーザーにウェルカムメールを送信するクラス
AdminNotifier ユーザー登録を管理者に通知するクラス

一方、分離前のUserServiceクラスを一文で説明しようとすると「バリデーション・永続化・メール送信・Slack通知をするクラス」になります。「~と~と~と~をするクラス」という説明になってしまう場合、責務が混在しているサインです。

凝集度と結合度

設計の良し悪しを測る2つの物差し

前回の記事を含めたここまで説明してきた原則「データと処理を1か所に集める」「カプセル化」「単一責任」の3つは、凝集度と結合度という共通の概念で説明できます。

良い設計を一言で表すなら、高凝集・疎結合です。

凝集度:クラスの中がどれだけまとまっているか

凝集度とは、1つのクラスの中にある要素が、どれだけ同じ目的のために存在しているかを表す度合いです。

凝集度が高いクラスは、プロパティとメソッドがすべて同じ関心事のために存在しています。内部設計編でみたOrderクラスがその例です。注文に関するデータを持ち、注文に関する計算だけを担っていました。

// OK: Order クラスが自分のデータに関するロジックを持つ
class Order
{
    public function __construct(
        private readonly int $id,
        private readonly array $items,  // ['price' => int, 'quantity' => int]
    ) {}

    public function totalWithTax(float $taxRate = 0.1): int
    {
        $subtotal = array_sum(
            array_map(
                fn($item) => $item['price'] * $item['quantity'],
                $this->items
            )
        );

        return (int) ($subtotal * (1 + $taxRate));
    }
}

凝集度が低いクラスは、関係の薄い要素が同居しています。単一責任の原則の例でみた分離前のUserServiceクラスがその例です。バリデーション・永続化・メール送信・Slack通知という、それぞれの異なる関心事が1つのクラスに詰め込まれていました。

結合度:クラスとクラスがどれだけ強く依存しあっているか

結合度とは、あるクラスが他のクラスの内部をどれだけ知っているかを表す度合いです。結合度が高いほど、一方の変更が他方に波及しやすくなります。

結合度が高い(密結合な)例を見てみます。

// NG: OrderControllerがOrderの内部構造を直接知っている
class OrderController extends Controller
{
    public function show(int $orderId): JsonResponse
    {
        $order = Order::findOrFail($orderId);

        // Orderの内部データに直接アクセスしている
        $total = 0;
        foreach ($order->items as $item) {
            $total += $item->price * $item->quantity;
        }

        return response()->json(['total' => $total]);
    }
}

OrderControllerOrderitemsというプロパティを持ち、各アイテムがpricequantityを持つことを知っています。これは良くない設計です。なぜならOrderの内部構造が変わるたびに、OrderControllerも修正が必要になってしまうからです。

それでは、結合度が低い(疎結合な)例と比較してみます。

// OK: OrderControllerはOrderに「聞く」だけで内部構造を知らない
class OrderController extends Controller
{
    public function show(int $orderId): JsonResponse
    {
        $order = Order::findOrFail($orderId);

        return response()->json(['total' => $order->totalWithTax()]);
    }
}

OrderControllerOrderの内部がどうなっているかを知りません。totalWithTax()というインターフェースだけを知っています。Orderの内部構造がどう変わっても、このメソッドのシグネチャが変わらない限りOrderControllerには影響が及びません。

凝集度を上げると、クラスの責務が明確になり、外部に公開するインターフェースが絞られます。その結果、他のクラスが知るべきことが減り、自然と結合度も下がります。高凝集と疎結合は、互いに引き合う性質を持っています。

まとめ

本記事で紹介した2つの概念をまとめます。

  • 単一責任の原則

    • 「1つのクラスは1つの責務を持つべき」という考え方
    • 責務が混在すると同じロジックがコピーされて増殖し、変更時の修正箇所が爆発する
    • 「〜と〜と〜をするクラス」という説明になるなら責務混在のサイン
    • 責務を分離すると変更の影響範囲が小さくなり、テストも書きやすくなる
  • 凝集度と結合度

    • 凝集度はクラス内部のまとまりの度合い。高いほど関心事が統一されている
    • 結合度はクラス間の依存の度合い。低いほど変更の影響が波及しにくい
    • 良い設計は「高凝集・疎結合」。この2つは互いに引き寄せ合う性質を持つ
    • クラス名が曖昧、一文で説明できない、内部構造を外から直接触っている、これらは設計を見直すサイン