アーキテクチャ ·

Value Objectで「不正な値が存在しない」状態を作る — 検証を型に閉じ込める

  • Value Object
  • DDD
  • 設計

この記事で扱う問題

クーポンを適用する UseCase が、次のような引数を受け取っているとします。

public function execute(string $orderId, int $discountAmount, string $currency): void

型の上では、$discountAmount に -500 が渡っても、$currency に '円' が渡っても、注文 ID の位置に顧客 ID の文字列を渡しても通ります。

Controller の手前でバリデーションはしているはずです。ただ、この UseCase はキューのジョブや Artisan コマンドからも呼ばれるかもしれません。UseCase の中から見ると、int $discountAmount が検証済みかどうかは分かりません。結果として「念のため」の検証があちこちに書かれ、書かれていない経路から不正な値が入ります。

「金額は0以上」「通貨が違う金額は足せない」といったルールが、型の外側にしか存在しない。これがプリミティブ型への執着(Primitive Obsession)の問題です。

判断

ルールを持つ値は、生成時に検証し、以後は変更できない小さなクラス(Value Object)にします。コンストラクタを通った値は必ず正しい、という状態を作ります(例外クラス InvalidMoney などの定義は省略します)。

enum Currency: string
{
    case JPY = 'JPY';
    case USD = 'USD';
}

final readonly class Money
{
    public function __construct(
        public int $amount,          // 最小単位(円、セント)での金額。小数を持ち込まないために int にする
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new InvalidMoney("金額は0以上である必要があります: {$amount}");
        }
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->amount + $other->amount, $this->currency);
    }

    public function subtract(self $other): self
    {
        $this->assertSameCurrency($other);

        // 結果が負になれば、コンストラクタが例外を投げる
        return new self($this->amount - $other->amount, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->amount === $other->amount && $this->currency === $other->currency;
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidMoney('通貨が違う金額は計算できません');
        }
    }
}

add() や subtract() は自分を書き換えず、新しいインスタンスを返します。計算の結果も必ずコンストラクタを通るので、計算の途中で負の金額ができる経路も残りません。なお、値引きが注文金額を超えるのは業務上ありうるケースなので、例外に任せず、UseCase で値引き額を注文金額までに抑えてから subtract() します。

複数の値の組み合わせにルールがある場合も、1つにまとめます。「開始は終了より前」は、開始日と終了日を別々の変数で持っている限り、どこにも書けないルールです。

final readonly class Period
{
    public function __construct(
        public DateTimeImmutable $start,
        public DateTimeImmutable $end,   // この時刻を含まない
    ) {
        if ($start >= $end) {
            throw new InvalidPeriod('開始は終了より前である必要があります');
        }
    }

    public function contains(DateTimeImmutable $at): bool
    {
        return $this->start <= $at && $at < $this->end;
    }
}

「終了を含むか」もこのクラスが決めるので、呼び出し側ごとに <= と < を書き分けて境界の1日がずれる、ということがなくなります。メールアドレスの大文字小文字をそろえるといった正規化の決めごとも、同じように Value Object のコンストラクタに置きます。

ID も型にします。例えば注文 ID を小文字の ULID で採番しているなら、その形式をここで確かめます。

final readonly class OrderId
{
    public function __construct(public string $value)
    {
        if (preg_match('/\A[0-9a-hjkmnp-tv-z]{26}\z/', $value) !== 1) {
            throw new InvalidOrderId('注文 ID の形式ではありません');
        }
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}

ID を型にする一番の利点は、形式の検証より、別の種類の ID を取り違えられなくなることです。冒頭の UseCase は次のようになり、中ではもう検証を書きません。

public function execute(OrderId $orderId, Money $discount): void

プリミティブ型からの変換は、外から値が入ってくる境界で1回だけ行います。HTTP では Controller で new OrderId($orderId) として UseCase に渡し、利用者向けのエラーメッセージはこれまでどおり FormRequest で出します。DB からは Repository の実装が組み立てます。キューのジョブには文字列や整数で載せ、handle() で変換します。ルートパラメータの ID は FormRequest で検証しないことが多いので、InvalidOrderId は Controller で 404 に変換します。それ以外で生成に失敗するなら、FormRequest の検証漏れかプログラムの誤りなので、4xx にはせず 500 としてログに残し、検証の側を直します。

なぜそう判断したか

基準は、状態変更の責任をオブジェクトに持たせるときと同じで、「このルールをどこで守るべきか」です。値のルールは、その値自身に守らせるのが一番漏れません。

Value Object にすると、「検証済みであること」を型で表せます。int は検証済みかどうかを語りませんが、Money は語ります。検証が1回で済み、どの経路から呼ばれても同じルールが効きます。

比較して選ばなかった方法は次のとおりです。

  • FormRequest の検証だけに頼る:HTTP 以外の経路(キュー・コマンド・取り込み処理)は素通りします
  • 検証用の関数を UseCase の先頭で呼ぶ:呼ぶかどうかが呼び出し側の記憶に頼るうえ、呼んだ後も型は int のままです
  • PHPStan / Psalm の型(positive-int など):軽く入れられますが、実行時には何も確かめず、「通貨が同じなら足せる」のようなルールも表せません。単純な制約なら、こちらで十分です

等価性は、何をもって等しいとするかをクラスの側で決めるために equals() で明示します(== は緩い比較、=== はインスタンスの同一性の比較です)。readonly にするのは、共有したインスタンスがどこかで書き換えられないようにするためで、不変だから生成時の検証がずっと有効なままになります。

どこから設計過剰になるか

  • ルールのない値まで包まない:メモ欄や表示名に専用のクラスを作っても、得るものはほとんどありません。取り違えやすい ID と、ルールや計算を持つ値(金額・期間・数量など)から始めます
  • DB が必要な検証は入れない:「このメールアドレスは未登録」は値そのものの性質ではありません。UseCase と DB の一意制約で守ります
  • 文脈でルールが変わる値を1つの型に押し込まない:差額や返金で負の金額が要るからと Money の「0以上」を外すと、すべての利用箇所で保証が消えます。差額には別の型を用意します
  • 読み取り側まで Value Object にしない:一覧表示の DTO や API のレスポンスは、プリミティブ型のままで十分です
  • 既存データの確認を先に:導入すると、ルールに合わない行は DB から読んだ時点で例外になります。先に SQL で洗い出し、データを直すかルールを見直すかを決めておきます

まとめ

  • ルールを持つ値は、生成時に検証し不変にした Value Object にして、不正な値を存在させない
  • 変換は境界で1回だけ行い、内側では「受け取った値は正しい」前提で書く
  • ルールのない値、DB が必要な検証、読み取り側の DTO までは Value Object にしない

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。