アーキテクチャ ·

$order->status = 'confirmed' と書かない理由 — 状態変更の責任をオブジェクトに持たせる

  • オブジェクト指向
  • DDD
  • 設計

この記事で扱う問題

注文を確定する処理を、次のように書くことがあります。

$order->status = 'confirmed';
$order->confirmed_at = now();
$order->save();

動きはします。ただ、この書き方だと次のことがどこにも保証されていません。

  • キャンセル済みの注文を確定してはいけない
  • 確定するときは、確定日時と確定者を必ず記録する
  • 在庫の引き当てが済んでいなければ確定できない

これらのルールは、この3行を書いた人が「知っていれば」守られます。別の画面やバッチで同じ状態変更を書いた人が知らなければ、壊れた状態のデータができます。

判断

状態の変更は、意味のある振る舞いとしてオブジェクトに持たせます。

final class Order
{
    private function __construct(
        private readonly OrderId $id,
        private OrderStatus $status,
        private ?DateTimeImmutable $confirmedAt,
        private ?int $confirmedBy,
    ) {
    }

    /** 新しい注文は、必ず「未確定」から始まる */
    public static function place(OrderId $id): self
    {
        return new self($id, OrderStatus::Pending, null, null);
    }

    /** 保存済みのデータから復元する。確定済みなら、確定日時と確定者がそろっている必要がある */
    public static function reconstitute(
        OrderId $id,
        OrderStatus $status,
        ?DateTimeImmutable $confirmedAt,
        ?int $confirmedBy,
    ): self {
        if ($status === OrderStatus::Confirmed && ($confirmedAt === null || $confirmedBy === null)) {
            throw new InvalidOrderState($id);
        }

        return new self($id, $status, $confirmedAt, $confirmedBy);
    }

    public function confirm(int $operatorId, DateTimeImmutable $now): void
    {
        if (! $this->status->canBeConfirmed()) {
            throw new OrderCannotBeConfirmed($this->id, $this->status);
        }

        $this->status = OrderStatus::Confirmed;
        $this->confirmedAt = $now;
        $this->confirmedBy = $operatorId;
    }
}

呼び出し側は $order->confirm($operatorId, $now) と書くだけです。どの状態から確定できるか、確定時に何を記録するかは、Order の中にしか書かれていません。

コンストラクタを private にしているのも同じ理由です。new Order($id, OrderStatus::Confirmed) のように、確定日時のない「確定済み」の注文を外から作れてしまうと、confirm() で守ったルールを生成の経路から破れます。新しく作るとき(place())と、保存済みのデータから戻すとき(reconstitute())で入口を分け、どちらの経路でも不変条件を確かめます。

なぜそう判断したか

私が設計で一番重視しているのは、**「誰がこの状態を変更する責任を持つのか」**です。

プロパティを外から書き換えられる設計では、その責任が呼び出し側すべてに分散します。呼び出し側が10か所あれば、ルールを10回正しく書く必要があります。

振る舞いとして閉じ込めると、

  • ルールが1か所にまとまり、変更も1か所で済む
  • 「確定できない状態」を作る経路が、そもそも存在しなくなる
  • confirm() のテストを書けば、確定のルールをまとめて検証できる

DDD の用語でいえば、不変条件を集約(Aggregate)に守らせる、ということです。ただ、私は用語から入るより、「このルールをどこで守るべきか」を突き詰めた結果としてこの形になる、と考えています。

Eloquent を使う場合でも、モデルのプロパティを外から直接書き換えるのではなく、意味のあるメソッド経由で変更するだけで、かなり改善します。

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

  • ルールがほとんどない項目(メモ欄、表示名など)まで振る舞いにする必要はありません。setter 相当で十分です
  • 状態が2つしかなく、今後も増えない見込みのフラグに、状態遷移の仕組みを作り込むのは過剰です
  • 管理者がデータを直接補正する運用がある場合は、「補正」という別の振る舞いを用意するほうが、ルールの抜け道を作るより安全です

まとめ

  • 外から状態を書き換える設計では、ルールが呼び出し側に散らばる
  • confirm() のような振る舞いに状態変更を閉じ込め、不変条件をオブジェクトに守らせる
  • 基準は「誰がこの状態を変更する責任を持つのか」

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