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

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

はじめに:なぜクラス設計が重要なのか

「動くコード」と「良いコード」の違い

プログラミングを始めてしばらく経つと、「とりあえず動くコードは書けるようになってきた」と感じる時期が来ます。テストしてみると実際期待通りの結果は返ってくるし、要件も満たしています。一見問題はないです。

しかし、しばらく経って仕様変更が入った時、こんな経験はないでしょうか。

「あの処理、どのファイルに書いたっけ?」

「ある処理を直したら、別の場所の処理が壊れてしまった」

「ある処理を読み解くのに30分以上もかかってしまった」

このようなことが起きるのは、「コードが動くこと」と「良い設計である」ことは別物だからです。

「動くコード」とは、今この瞬間に要件を満たすコードのことです。処理の流れさえ正しければ成立しますし、どこに何が書かれているのかは問われません。

一方「良いコード」とは、今だけではなく将来変更するときにも壊れにくいコードのことです。要件は変わるし、バグも出るし、機能も追加されていく。これらを前提にしたコードであるかが良い設計かどうかの本質的な基準になります。

そして「良いコード」を書くために重要になるのがクラス設計です。

設計の悪さが引き起こす具体的な問題

設計が悪いコードは、時間の経過とともに次の3つの問題として現れます。

1. 修正が怖くなる

関係のないはずの処理が同じ場所に混在していると、1か所を直したときの影響範囲が読めなくなります。「ここを変えたら他の箇所が壊れてしまうかもしれない」という感覚が生まれ、本来シンプルなはずの修正に過剰なコストがかかります。

2. テストができない(しにくい)​

ある処理を検証しようとしたとき、それがデータベースアクセスやメール送信などと密結合していると、その処理単体でのテストが書けなくなります。「全部動かさないと確認できない」状態は、品質の担保を困難にします。

※密結合・・・2つの処理の依存関係が密に結合しており、切り離して動作させることができない状態のこと

3. コードが読めない

1つのクラスやメソッドが複数の目的を持っていると、「このコードは何をしているのか」を理解するだけで多大な時間を要します。書いた本人でさえ、数週間後には解読に苦労することになります。

こうした問題は、最初から悪意を持って書かれたコードから生まれるわけではありません。「とりあえず動かす」という積み重ねの結果として、気づいた時には手がつけられない状態になっています。

本記事では、こうした問題を未然に防ぐためのクラス設計の基本原則を解説していきます。サンプルコードはPHP(Laravel)で書いていきますが、原則自体はどの言語やフレームワークでも共通する考え方になります。

なお、「クラスをどう分けるか」という責務の分離や依存関係の管理については、次回の​「クラス設計の基本:責務分離編」​で扱います。

クラスの責務:データと処理を1つの場所に集める

Controllerに全部書いてしまう問題

LaravelでWebアプリのコードを書き始めると、多くのコードはControllerに集まりやすいです。

例えば、ECサイトで注文の合計金額を計算して画面に返す処理を考えてみます。

// NG: OrderController にすべての処理が書かれている
class OrderController extends Controller
{
    public function show(int $orderId): JsonResponse
    {
        $order = Order::findOrFail($orderId);

        // 合計金額の計算ロジックがControllerに書かれている
        $total = 0;
        foreach ($order->items as $item) {
            $total += $item->price * $item->quantity;
        }

        // 税込み計算もここで行う
        $totalWithTax = $total * 1.1;

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

このコードは動きますが、以下のような場合に問題が顕在化します。

  • 税率が変わった→Controllerを探して直す
  • 別のエンドポイントでも合計金額が必要になった→同じ計算を別のControllerにコピペする
  • 合計金額の計算をテストしたい→HTTPリクエストを組み立てえないとテストできない

これらはすべて、本来Orderクラスが持つべき知識がControllerに漏れ出していることで起きています

「データを持つものが処理を担う」という原則

オブジェクト指向の核心的な考え方のひとつに、データとそのデータに関する処理は同じクラスに置くというものがあります。

Orderクラスは注文のデータを持っています。であれば、注文に関する計算や判断も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));
    }
}

// Controller はOrderに「聞く」だけになる
class OrderController extends Controller
{
    public function show(int $orderId): JsonResponse
    {
        $order = Order::findOrFail($orderId);

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

このコードではControllerは何も計算しておらず、Orderクラスにただ問い合わせているだけになります。

関連するデータと処理をクラスにまとめることで、他のControllerでも注文合計の計算がしたくなったときに、コピペではなくOrderクラスを呼び出すことができるようになります。また、注文合計の計算に不備があっても、呼び出しているすべてのControllerを修正することなく、Orderクラスを修正するだけで済むようになります。

カプセル化:public/privateの適切な使い分け

publicプロパティは「誰でも書き換えられる」状態

前のセクションで整理したOrderクラスに、在庫管理の仕組みを追加することを考えてみます。商品クラスが在庫数を持ち、注文時に在庫を減らす処理が必要になったとします。

何も考えずに実装すると、以下のようになります。

// NG: プロパティがpublicで外から直接書き換えられる
class Product
{
    public int $stock;
    public string $name;
    public int $price;

    public function __construct(string $name, int $price, int $stock)
    {
        $this->name  = $name;
        $this->price = $price;
        $this->stock = $stock;
    }
}

このクラスを使う側では以下になります。

// 注文処理のどこか
$product = Product::findOrFail($productId);
$product->stock -= $quantity;  // 外から直接書き換え
$product->save();

これでコードは動きます。しかし、この設計にはいくつかの不備があります。

第一に、在庫数がマイナスの値になっていても気づきません。

$product->stock -= 100;  // 在庫が10しかなくてもエラーにならない

第二に、在庫を減らす処理がアプリの複数個所に存在する場合、「在庫が足りているか」のチェックも各所に分散します。1か所でも漏れれば即バグにつながります。

第三に、変更の意図が伝わりません。$product->stock -= $quantityというコードは「在庫数を減らした」という事実は伝えますが、「なぜ減ったのか(注文なのか、廃棄なのか)」という文脈は失われます。

カプセル化とは

こうした問題を防ぐ設計上の考え方がカプセル化です。

カプセル化とは、クラスの内部状態を外部から直接触れないようにし、決められた操作を通じてのみ変更できるようにすることです。データと、そのデータを正しく扱うためのルールを、同じクラスの中に閉じ込めるイメージです。

カプセル化の名前の通り、外からは内側は見えません。外部からできるのは、クラスが「これならやっていいですよ」と公開している操作だけです。

PHPでは、privatepublicといったアクセス修飾子がカプセル化の主な道具になります。

getter/setterより「意図を持ったメソッド」

カプセル化の典型的な方法として、getter/setterを導入する方法があります。

// getter/setter を追加した例(まだ不十分)
class Product
{
    private int $stock;

    public function getStock(): int
    {
        return $this->stock;
    }

    public function setStock(int $stock): void
    {
        $this->stock = $stock;
    }
}

プロパティはprivateになりましたが、setStock()を呼べば結局何でも上書きできてしまいます。これではprivateにしただけで、本質的には何も守られていません。

本来やりたいことは「在庫を外から直接変更させない」ことではなく、「在庫に関連する操作を、正しい手順でしか行えなくする」ことです。

// OK: 意図を持ったメソッドで操作する
class Product
{
    private int $stock;

    public function __construct(
        private readonly string $name,
        private readonly int $price,
        int $stock,
    ) {
        if ($stock < 0) {
            throw new \InvalidArgumentException('在庫数は0以上である必要があります');
        }
        $this->stock = $stock;
    }

    // 「在庫を減らす」という操作に名前と制約を持たせる
    public function decreaseStock(int $quantity): void
    {
        if ($quantity <= 0) {
            throw new \InvalidArgumentException('減少数は1以上である必要があります');
        }
        if ($this->stock < $quantity) {
            throw new \DomainException('在庫が不足しています');
        }

        $this->stock -= $quantity;
    }

    // 「在庫を補充する」も同様
    public function replenishStock(int $quantity): void
    {
        if ($quantity <= 0) {
            throw new \InvalidArgumentException('補充数は1以上である必要があります');
        }

        $this->stock += $quantity;
    }

    public function getStock(): int
    {
        return $this->stock;
    }

    public function isInStock(): bool
    {
        return $this->stock > 0;
    }
}

呼び出し側は以下になります。

$product->decreaseStock($quantity);  // 在庫不足なら例外が投げられる

在庫チェックのロジックはクラスの中に1か所だけ存在し、どこから呼び出されても同じルールが適用されます。

public / privateの判断基準

どちらにすべきか迷った時のシンプルな基準は、「このクラスを使う側が知る必要があるか」です。

項目 判断
外部から操作・参照される必要がある public
クラス内部の実装詳細にすぎない private
サブクラスで使う可能性がある protected

Productクラスでいうと、$stockの値そのものは内部状態であり、外部から知りたいのは「在庫があるか(isInStock())」や「在庫を減らしたい(decreaseStock())という操作です。外部は$stockを直接触る必要はありません。

迷ったらまずprivateにしましょう。外部への公開が必要になった時にpublicに変えればいいだけです。

状態の制御:ミュータブルとイミュータブルの設計判断

ミュータブルとは

前のセクションで設計したProductクラスは、decreaseStock()replenishStock()を通じて在庫数が変化します。このように生成後に内部状態が変わるオブジェクトをミュータブルと呼びます。

在庫数は注文のたびに増減するものなので、ミュータブルな設計は自然です。しかし、「状態が変わる」という性質は、扱い方を間違えると気づきにくいバグを生みます。

ミュータブルが引き起こす問題

ECサイトでよくある「注文確認画面で表示した金額」と「実際に決済した金額」がずれる、というバグを考えてみます。

原因の一つとして考えられるのが、金額を表すオブジェクトが意図せず書き換えられているケースです。

// NG: 金額クラスがミュータブルな設計
class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}

    public function add(int $value): void
    {
        $this->amount += $value;  // 自身の状態を変更する
    }
}

// 注文サマリーを組み立てる処理
$subtotal = new Money(10000, 'JPY');

// 送料を加算する(つもりが、元のオブジェクトを書き換えてしまう)
$subtotal->add(800);

// この時点で $subtotal はもう「小計」ではなく「小計+送料」になっている
echo $subtotal->amount;  // 10800 (小計として使いたかったのに)

$subtotalは小計として保持しておきたい値でしたが、add()を呼んだ時点で元の値が上書きされています。参照を経由して複数個所から同じオブジェクトを使いまわしている場合、この問題はさらに追跡がしづらくなります。

イミュータブルとは

これに対し、生成後に内部状態が変わらないオブジェクトをイミュータブルと呼びます。

イミュータブルなオブジェクトは「変更」をしません。「変更した結果」を表す新しいオブジェクトを返すという設計になります。

// OK: Moneyクラスをイミュータブルに設計する
class Money
{
    public function __construct(
        private readonly int $amount,
        private readonly string $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('金額は0以上である必要があります');
        }
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('通貨単位が異なります');
        }

        // 自身は変更せず、新しいオブジェクトを返す
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function amount(): int
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }
}

$subtotal  = new Money(10000, 'JPY');
$shipping  = new Money(800, 'JPY');

// add()は新しいMoneyを返すため、$subtotalは変わらない
$total = $subtotal->add($shipping);

echo $subtotal->amount();  // 10000(小計のまま保たれている)
echo $total->amount();     // 10800

$subtotaladd()を呼んでも変わりません。元の値が必要なら$subtotalを、合計が必要なら$totalを参照すればよく、コードの意図が明確になります。

readonlyを使うと、コンストラクタ以外からのプロパティへの代入をPHPが言語レベルで禁止するため、イミュータブルな設計を強制する手段として有効です。

ミュータブルとイミュータブル、どちらを選ぶか

両者は「どちらが優れているか」という話ではなく、対象の性質に合わせて使い分けるものです。判断の目安は以下の通りです。

項目 ミュータブル イミュータブル
向いている対象 状態が変化することが自然なもの 値そのものを表すもの
具体例 在庫数、注文ステータス、カート 金額、日付、住所、氏名
メリット 状態の更新をそのまま表現できる 意図しない書き換えが起きない
注意点 変更の影響範囲を意識する必要がある 変更のたびに新しいオブジェクトが生まれる

特に「値そのものを表すもの」という基準がポイントです。金額・日付・住所といった概念は、「10,000円が12,000円に変化する」のではなく、「10,000円と12,000円は別の値だ」と捉えるのが自然です。

一方、Productクラスの在庫数のように「同じ商品の在庫が増減する」という概念はミュータブルが自然です。在庫が変わるたびに新しいProductオブジェクトを生成するのは直感に反しています。

まとめ

本記事で扱った3つの原則を振り返ります。

  • データと処理を1か所に集める

    • Controllerにロジックを書くと、知識が散らばり変更に弱くなる
    • データを持つクラスが、そのデータに関する処理を担うのが基本
    • 凝集度が高いクラスはテストが書きやすく、変更の影響範囲が小さい
  • カプセル化

    • publicプロパティは誰でも・どこからでも書き換えられる状態を意味する
    • getter/setterはプロパティをprivateにするだけで、操作の安全性は保証しない
    • 「何ができるか」を表す意図を持ったメソッドにすることで、不正な状態への遷移を防げる
    • 迷ったらまずprivateにする。公開範囲は後から広げるより最初から狭く保つほうが安全
  • ミュータブルとイミュータブル

    • ミュータブルなオブジェクトは生成後に状態が変わる。意図しない書き換えに注意が必要
    • イミュータブルなオブジェクトは状態が変わらず、変更結果は新しいオブジェクトとして返す
    • readonlyを使うとPHPの言語レベルでイミュータブルを強制できる
    • 「状態が変化するもの」はミュータブル、「値そのものを表すもの」はイミュータブルが基本的な判断基準

次回の​「クラス設計の基本:責務分離編」​では、単一責任の原則・凝集度・結合度を取り上げ、「クラスをどう分けるか」という観点で設計を掘り下げます。