$order->status = 'confirmed' と書かない理由 — 状態変更の責任をオブジェクトに持たせる
この記事で扱う問題
注文を確定する処理を、次のように書くことがあります。
$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()のような振る舞いに状態変更を閉じ込め、不変条件をオブジェクトに守らせる- 基準は「誰がこの状態を変更する責任を持つのか」