OOP と DDD の戦術パターン — 原則がどこに現れるか
前章までで、値オブジェクトからドメインイベントまでの戦術パターンを実装しながら見てきました。第 2 部の最後に、書いたコードを別の角度から読み直します。
値オブジェクトも集約もドメインサービスも、PHP のクラスとして書かれています。オブジェクト指向の原則がそのまま下敷きになっている一方で、どのパターンも原則だけでは決まらない判断を持っています。本章はその対応と、パターンの側が足している判断を並べます。
原則そのものは説明しません。カプセル化やコンポジションが何かは PHPクラス設計ガイド が扱います。本章は各原則の要点を 1 段落だけ置き、そこから戦術パターンへ渡します。
矢印は「この原則がこのパターンに現れている」という対応です。原則からパターンが導かれるわけではありません。同じカプセル化を使っていても、値オブジェクトは「いつ値の妥当性を検査するか」という別の判断を加えています。その加えた分が、クラス設計と DDD の戦術設計の差にあたります。3 つ目だけは対応が条件付きで、ドメインサービスがポリモーフィズムを使うのはルールの種類が増えたときに限られます。
原則の側はどこで読むか
| 原則 | 読む場所 |
|---|---|
| カプセル化、tell, don't ask | PHPクラス設計ガイド 第 3 章 |
| 継承とコンポジション | 同 第 5 章 |
| インターフェースと抽象への依存 | 同 第 6 章 |
| 凝集度・結合度と SOLID | 同 第 7 章 |
本章が取り上げるのは 3 つです。オブジェクト指向の説明でカプセル化と並ぶことの多い継承を外してコンポジションを置いているのは、戦術パターンが継承をほとんど使わないためです。値オブジェクトは第 8 章で final を付けて閉じていますし、ドメイン層のエンティティも集約も継承階層を作りません。ドメインモデルの階層は、クラスの継承ではなくオブジェクトの包含で表します。
カプセル化 — 判断をオブジェクトの内側に置く
PHPクラス設計ガイドの第 3 章は、在庫を減らす処理を例に tell, don't ask を説明しています。呼び出し側が数量を聞いてから引き算するのではなく、reduce(5) と命令して、足りるかどうかの判断は Stock の内側に置く形です。
第 8 章の Money も同じ形をしています。
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('通貨が異なります');
}
return new Money($this->amount + $other->amount, $this->currency);
}
呼び出し側は通貨が揃っているかを確かめません。その判断は Money の側にあります。ここまでは、ドメインモデルでなくても成り立つカプセル化です。
値オブジェクトが一段進んでいるのは、検査のタイミングです。Stock::reduce() が検査するのはメソッドが呼ばれたときですが、Money はコンストラクタでも検査していて、負の金額を持つ Money は生成すらできません。
public function __construct(
private readonly int $amount,
private readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('金額は0以上である必要があります');
}
}
生成時に検査し、readonly で書き換えを封じ、演算は新しいインスタンスを返す。この 3 つが揃うと「存在している Money は必ず有効である」が成り立ち、受け取った側は検査を省けます。「金額は 0 以上」のように、オブジェクトが常に満たしていなければならない規則を不変条件 (invariant) と呼びます。値オブジェクトはその検査を生成時に終えるので、不変条件が破れたインスタンスはそもそも作れません。
カプセル化だけなら、セッターを持つ可変オブジェクトでも満たせます。値オブジェクトはそこに不変性を足すことで、検査の場所を生成の一点に寄せています。
コンポジション — 集約はどこまでを持つか
PHPクラス設計ガイドの第 5 章は、継承 (is-a) とコンポジション (has-a) を比べ、軸が増えるときに継承がクラス数の組み合わせ爆発を起こすことを示しています。そこでの問いは「継承するか、持つか」です。
第 10 章の集約は、この問いの次に来ます。持つと決めたうえで、どこまでを持つかを決めます。
| 相手の位置 | 持ち方 | 例 |
|---|---|---|
| 集約の内側 | オブジェクトごと持つ | Order が OrderLine と ShippingAddress を持つ |
| 集約の外側 | 識別子だけ持つ | OrderLine が ProductId を持ち、Product は持たない |
OrderLine が Product そのものを持たないのは、オブジェクト指向の原則から出てくる結論ではありません。Product を持ってもコンポジションとしては正しく、型検査も通ります。持たない理由は、集約が整合性の境界だからです。Order を保存するときに Product まで一緒に書き込むと、商品の正しい状態をどちらの集約が持っているかが決まらなくなります。
has-a の範囲を決めることは、1 回のトランザクションで何を書き換えるかを決めることでもあります。範囲を広げるほど同時に更新する行が増え、ロックの競合も増えます (第 19 章)。コンポジションの良し悪しがクラス図の上で決まるのに対して、集約の境界は保存と競合まで含めて決まります。
ポリモーフィズム — ルールの種類が増えたとき
PHPクラス設計ガイドの第 5 章と第 6 章は、この仕組みをインターフェースと実装の差し替えという形で扱っています。呼び出す側から見た型を変えずに中身を入れ替えると、同じ名前のメソッドを呼んでも渡された実装によって別の処理が動きます。
第 11 章の送料計算サービスは、地域による追加料金を match で分けています。
private function calculateRegionFee(ShippingAddress $address): Money
{
return match ($address->prefecture()) {
'北海道', '沖縄県' => new Money(500, 'JPY'),
default => new Money(0, 'JPY'),
};
}
ポリモーフィズムを覚えたあとだと、この match をインターフェースと実装クラスに置き換えたくなります。置き換えが割に合うかは、分岐がこれからどう変わるかで決まります。
match のままでよいのは、どの分岐も「金額を返す」だけで揃っていて、条件が一目で読めるうちです。上のコードは 5 行で全体が見え、地域を 1 つ足すのも 1 行です。
インターフェースに分けるのは、分岐が別々の理由で変わり始めたときです。離島は重量による段階制になり、沖縄はキャンペーン期間の判定が入る、という状態になると、1 つの match を別々の都合で何度も書き換えることになります。
interface ShippingFeeRule
{
public function supports(ShippingAddress $address): bool;
public function fee(Order $order): Money;
}
送料計算サービスは適合するルールを選んで呼ぶ形になり、地域ごとの条件がサービスから消えます。送料無料の閾値のように地域に依らない判定は、サービスに残ります。ルールごとにクラスを作れば、追加は実装を 1 つ増やす操作になり、既存のルールを読まずに済みます。PHPクラス設計ガイドの第 5 章が通知のチャネルを NotificationChannel として切り出したのと同じ形です。
分けても第 11 章の判断基準は変わりません。ルールの実装は状態を持たず、リポジトリや外部 API に触れません。触れる必要が出てきたら、その処理はドメインサービスではなくユースケースの担当です。
この章で扱わない 2 つの対応
インターフェースにまつわる対応のうち、2 つを後ろの章に送っています。
- 抽象への依存 (DIP): 上位の処理が具象クラスでなくインターフェースに依存する形は、クラス 1 つの設計ではなく層と層の関係です。第 14 章が 4 層構成と合わせて扱います
- リポジトリインターフェースの置き場所: インターフェースをドメイン層に、実装をインフラ層に置く配置は、DIP を Laravel のディレクトリ構成に落とした形です。第 17 章が扱います
どちらも「インターフェースを切る」という同じ道具を使いますが、決めているのは依存の向きで、本章の 3 つとは問いが違います。
メソッド呼び出しとメッセージ
ここまでの 3 つは、どれも同じプロセスの中でオブジェクトが直接メソッドを呼ぶ形でした。呼ぶ側は相手のクラスか、少なくともインターフェースを知っています。
第 12 章のドメインイベントは、この形を一段外します。OrderConfirmed を発行する側は、誰がそれを受け取るかを知りません。受け手はリスナーとして後から足せます。
ただし Laravel のイベントとリスナーは、同じアプリケーションの中で動きます。キューに載せれば実行は後回しになりますが、送り手と受け手が同じコードベースにいることは変わりません。
別のアプリケーションへ知らせたい場合や、受け手が停止していても後で届いてほしい場合は、間に仲介役が要ります。それがメッセージブローカーです。本章の 3 つがクラスの内側をどう作るかの話だったのに対して、そちらはプロセスをまたいだ通信の話になります。
まとめ
| 原則 | 現れる場所 | パターンの側が足している判断 |
|---|---|---|
| カプセル化 | 値オブジェクト (第 8 章) | 検査を生成時に寄せ、存在するインスタンスを常に有効に保つ |
| コンポジション | 集約 (第 10 章) | has-a の範囲を、整合性とトランザクションの境界として決める |
| ポリモーフィズム | ドメインサービス (第 11 章) | 分けるかどうかを、分岐が独立に変わるかで決める |
原則を知っていても、線をどこに引くかまでは決まりません。戦術パターンは、その線の引き方を型として持っています。
次章からは第 3 部に入り、ここまでのドメインモデルを Laravel のどのディレクトリに、どの依存の向きで配置するかを見ていきます。
参考資料
- PHPクラス設計ガイド — 本章が前提にしているオブジェクト指向の原則
- Eric Evans『エリック・エヴァンスのドメイン駆動設計』(翔泳社) 第 2 部 — 戦術パターンの原典