これは私のドメイン駆動設計の学習用にAIに作らせた資料です。それでも良ければ読んでみてください。
はじめに
対象読者:ドメイン駆動設計を学びたいエンジニア
前提知識:オブジェクト指向プログラミングの基礎(クラス、継承、カプセル化)、簡単なWebアプリケーション開発の経験
サンプルコード:Python 3.10+(match文や X | None 記法を使用します。型ヒント・dataclasses・abc を前提にしています)
この教材の使い方
- 各章は「解説 → コード例 → 理解度チェック」で構成されています
- 理解度チェックの解答は各章末の**「▶ 解答例を見る」(折りたたみ)**に入れてあります。まず自分の言葉で答えてから開いてください
- 第1〜3章が「戦略的設計」、第4〜8章が「戦術的設計」、第9〜10章が「アーキテクチャと実践」です
- 目安学習時間:1章あたり30分〜1時間、全体で10〜15時間
Pythonでのコードの読み方(この教材の約束)
本教材のサンプルは、DDDの意図が伝わることを優先して次の方針で書いています。
-
値オブジェクトは
@dataclass(frozen=True)で表現します。これだけで「不変(代入不可)」と「値による等価性(__eq__)」がPython標準で手に入ります。妥当性検証は__post_init__に書きます -
エンティティは通常のクラスで書き、
__eq__/__hash__をID(同一性)だけで実装します -
リポジトリなどの抽象は
abc.ABC+@abstractmethodで「インターフェース」を表現します - Pythonには「privateコンストラクタ」がないため、生成規則を強制したいときは**ファクトリ用のクラスメソッド(
from_...)**を用意し、検証は__post_init__に集約します
目次
- DDDとは何か、なぜ必要なのか
- ユビキタス言語
- 境界づけられたコンテキスト
- 値オブジェクトとエンティティ
- 集約
- リポジトリとファクトリ
- ドメインサービスとアプリケーションサービス
- ドメインイベント
- DDDとアーキテクチャ
- 実践演習:ミニECサイトの設計
- よくあるアンチパターンと誤解
- 次のステップと参考文献
第1章 DDDとは何か、なぜ必要なのか
1.1 DDDの定義
ドメイン駆動設計(Domain-Driven Design、以下DDD)は、Eric Evansが2003年の著書で提唱したソフトウェア設計の考え方です。一言でいえば、
ソフトウェアの複雑さの中心は「ドメイン(業務領域)」にあり、その複雑さに立ち向かうために、ドメインの知識をモデルとしてコードの中心に据える設計手法
です。
ここで重要な用語を整理します。
| 用語 | 意味 | 例(ECサイトの場合) |
|---|---|---|
| ドメイン | ソフトウェアが解決したい業務領域 | 「通販事業」全体 |
| ドメインモデル | ドメインの知識を抽象化・構造化したもの | 「注文は複数の注文明細を持ち、確定後はキャンセル期限内のみ取消可能」というルールの表現 |
| ドメインエキスパート | 業務に詳しい人 | 商品企画担当者、CS担当者、物流担当者 |
1.2 なぜDDDが必要になるのか
若手エンジニアが最初にぶつかる「つらいコード」の多くは、技術的な問題ではなく業務ルールの置き場所の問題です。
よくある失敗:ドメインロジックの散在
# ❌ サービス関数に全ロジックがベタ書き(トランザクションスクリプト)
class OrderService:
def cancel_order(self, order_id: str) -> None:
order = self._db.query("SELECT * FROM orders WHERE id = %s", order_id)
# キャンセル可否のルールがここに直書き
if order["status"] in ("SHIPPED", "DELIVERED"):
raise ValueError("発送済みの注文はキャンセルできません")
if days_between(order["confirmed_at"], now()) > 7:
raise ValueError("確定から7日を過ぎた注文はキャンセルできません")
self._db.execute("UPDATE orders SET status = 'CANCELED' WHERE id = %s", order_id)
このコードの問題は、動かないことではありません。「キャンセルできる条件」という業務ルールが、注文(Order)ではなくサービスに書かれていることです。同じルールが管理画面用のAPI、バッチ処理、CS向けツールにコピペされ、仕様変更のたびに全箇所を直すことになります。これが「ドメインロジックの散在」です。
DDD的な発想の転換
# ⭕ ルールをOrder自身に持たせる
class Order:
def cancel(self, now: datetime) -> None:
if not self._is_cancelable(now):
raise OrderNotCancelableError(self._id)
self._status = OrderStatus.CANCELED
def _is_cancelable(self, now: datetime) -> bool:
return (not self._status.is_shipped_or_later()
and self._confirmed_at.within_days(7, now))
「注文がキャンセルできるかどうかは、注文自身が知っている」——この状態をドメインモデルが振る舞いを持つと表現します。DDDの戦術的パターン(第4章以降)は、すべてこの状態を実現するための道具です。
1.3 戦略的設計と戦術的設計
DDDは大きく2つの層に分かれます。初学者はパターン(戦術)から入りがちですが、DDDの本質はむしろ戦略側にあります。
| 戦略的設計 | 戦術的設計 | |
|---|---|---|
| 関心事 | 何をどこまでモデリングするか、チーム・システムの分割 | モデルをコードでどう表現するか |
| 主なパターン | ユビキタス言語、境界づけられたコンテキスト、コンテキストマップ | エンティティ、値オブジェクト、集約、リポジトリ、ドメインイベント |
| 失敗するとどうなるか | 巨大な神モデル、チーム間の翻訳コスト爆発 | 貧血モデル、手続き的なコード |
1.4 DDDを採用すべき場面・すべきでない場面
DDDは万能薬ではありません。判断の目安:
- 向いている:業務ルールが複雑で頻繁に変わる(金融、EC、物流、SaaSの中核機能)、長期間保守するシステム
- 向いていない/過剰:CRUDだけの管理画面、使い捨てのプロトタイプ、技術的複雑さが中心のシステム(例:画像変換パイプライン)
「複雑さがドメインにあるならDDD、技術にあるなら別のアプローチ」と覚えてください。
📝 理解度チェック(第1章)
Q1-1. 「ドメイン」「ドメインモデル」「ドメインエキスパート」をあなたの現在(または過去)のプロジェクトに当てはめて、それぞれ具体的に説明してください。
Q1-2. 1.2のコード例で、キャンセル可否の判定を OrderService から Order に移すことで得られるメリットを2つ以上挙げてください。
Q1-3. 次のうち、DDDの採用が最も効果的と考えられるシステムはどれですか。理由も答えてください。
(a) 社内備品の貸出を記録するだけの簡単な台帳アプリ
(b) 保険商品ごとに複雑な料率計算・約款ルールを持つ契約管理システム
(c) 動画ファイルを一括で別フォーマットに変換するバッチツール
Q1-4. 「戦略的設計」と「戦術的設計」の違いを1〜2文で説明してください。
▶ 解答例を見る
A1-1.(解答例)受発注システムなら、ドメイン=「卸売業の受発注業務」、ドメインモデル=「注文・出荷・請求の関係と、与信限度額チェックなどのルールを表現したもの」、ドメインエキスパート=「営業事務担当者・経理担当者」。自分のプロジェクトの具体名で答えられていればOK。
A1-2. ①ルールが1箇所に集まり、仕様変更時の修正漏れがなくなる ②Order 単体でユニットテストできる(DB不要) ③コードを読めば「キャンセルの業務ルール」が分かる(コードがドキュメントになる)——など。
A1-3. (b)。理由:業務ルール(料率計算・約款)が複雑で変更が多く、複雑さの中心がドメインにあるため。(a)はCRUDで十分、(c)は複雑さが技術側にある。
A1-4. 戦略的設計は「どの範囲をどうモデリングし、システム・チームをどう分割するか」というマクロの設計。戦術的設計は「モデルをエンティティや集約などのパターンでコードにどう落とすか」というミクロの設計。
第2章 ユビキタス言語
2.1 ユビキタス言語とは
**ユビキタス言語(Ubiquitous Language)**とは、ドメインエキスパートと開発者が共通で使う、プロジェクト内の統一された語彙のことです。「ユビキタス=遍在する」の名の通り、会話・ドキュメント・コード・テスト名・DBカラム名のすべてで同じ言葉を使うことを目指します。
2.2 言葉のズレが引き起こす問題
現場でよくあるズレの例:
| 業務側の言葉 | コード上の名前 | 起きる問題 |
|---|---|---|
| 「注文を確定する」 | update_status(2) |
コードを読んでも業務が見えない |
| 「会員」と「ユーザー」は別物(会員=課金者) | どちらも User
|
会員限定機能のバグが多発 |
| 「引当(在庫の確保)」 | stock_flag = 1 |
新人が仕様を誤解する |
言葉のズレは翻訳コストを生み、翻訳のたびに情報が欠落し、それがバグになります。
2.3 ユビキタス言語をコードに反映する
# ❌ 業務の言葉が消えている
order.set_status(2)
if user.type == 1:
...
# ⭕ 業務の言葉がそのままコードに現れる
order.confirm() # 「注文を確定する」
if member.is_premium(): # 「プレミアム会員なら」
...
inventory.allocate(quantity) # 「在庫を引き当てる」
ポイント:
- 動詞も語彙に含める。「確定する(confirm)」「引き当てる(allocate)」「取り消す(cancel)」など、業務上の操作名をメソッド名にする
- 用語集(グロッサリー)を作り、育てる。「会員とユーザーの違い」のような定義をドキュメント化し、新しい言葉が出たら更新する
- エキスパートの言葉に違和感があれば質問する。会話の中で「その『キャンセル』と『取消』は同じ意味ですか?」と確認する行為自体がモデリング
2.4 ユビキタス言語は「コンテキストごと」に作る
重要な注意点として、ユビキタス言語は会社全体で1つではありません。同じ「商品」という言葉でも、販売部門では「価格と説明文を持つ売り物」、物流部門では「重さとサイズを持つ荷物」を指します。この「言葉が通用する範囲」こそが次章の境界づけられたコンテキストです。
📝 理解度チェック(第2章)
Q2-1. ユビキタス言語を「会話だけでなくコードにも」反映すべき理由を、翻訳コストの観点から説明してください。
Q2-2. 次のコードをユビキタス言語の観点で改善してください(業務:図書館の貸出システム。「貸出」「返却」「延滞」という言葉が使われている)。
book.set_flag(1) # 貸出処理
book.set_flag(0) # 返却処理
if book.check_date(today): # 延滞チェック
...
Q2-3. あなたのプロジェクトで「同じ言葉なのに人によって意味が違う」用語、または「業務では使うのにコードには現れない」用語を1つ挙げ、どう定義・命名し直すべきか提案してください。
Q2-4. 「ユビキタス言語は組織全体で1つに統一すべきだ」という主張は正しいですか。理由とともに答えてください。
▶ 解答例を見る
A2-1. 業務の言葉とコードの名前が違うと、仕様書⇔コード間で毎回「翻訳」が必要になる。翻訳のたびに解釈違いが混入し、バグや仕様誤解の原因になる。同じ言葉を使えば翻訳が不要になり、エキスパートとコードレビューレベルの会話も可能になる。
A2-2.(解答例)
loan = book.lend_to(member, today) # 貸出
book.return_book(today) # 返却(return は予約語なので工夫する)
if loan.is_overdue(today): # 延滞
...
フラグ操作ではなく「貸出」「返却」「延滞」という業務語彙をメソッド名にする。貸出を Loan という独立した概念として抽出できるとさらに良い。
A2-3. 自由回答。「定義を1文で書く」「区別が必要なら別の型名(クラス名)を与える」が含まれていれば良い。
A2-4. 誤り。言葉の意味はコンテキストによって正当に異なる(販売の「商品」と物流の「商品」)。無理に統一すると、どのコンテキストにも合わない曖昧なモデルになる。統一すべきなのは「1つの境界づけられたコンテキストの中」での言葉。
第3章 境界づけられたコンテキスト
3.1 境界づけられたコンテキストとは
**境界づけられたコンテキスト(Bounded Context)**とは、特定のドメインモデルとユビキタス言語が一貫して通用する範囲のことです。DDDの戦略的設計における最重要概念です。
例:ECサイトにおける「商品」
同じ「商品」でも、コンテキストが違えば属性も振る舞いも別物です。これを1つの巨大な Product クラスに詰め込むと、販売の都合の変更が物流のコードを壊す「神クラス」が生まれます。分けることで、それぞれのモデルをシンプルに保てるのです。
3.2 コンテキストの境界を見つけるヒント
- 言葉の意味が変わる場所:「商品」の意味が変わるなら、そこが境界
- 組織の境界:販売部と物流部で担当者が違うなら、モデルも分かれることが多い(コンウェイの法則)
- 変更理由の違い:「セール機能の追加」と「配送業者の変更」が独立して起きるなら別コンテキスト
3.3 コンテキストマップ
複数のコンテキストがどう連携するかを表したものがコンテキストマップです。代表的な関係パターン:
| パターン | 説明 | 使いどころ |
|---|---|---|
| 顧客/供給者(Customer-Supplier) | 上流チームが下流チームの要望を聞いて開発する | 社内の協力的なチーム間 |
| 順応者(Conformist) | 下流が上流のモデルにそのまま従う | 上流に交渉力がない場合(外部の大手API等) |
| 腐敗防止層(Anticorruption Layer, ACL) | 外部のモデルを自分のモデルに変換する層を挟む | レガシーシステムや外部サービスとの連携 |
| 公開ホストサービス(Open Host Service) | 多数の利用者向けに公開プロトコルを提供 | 社内共通基盤API |
| 共有カーネル(Shared Kernel) | 一部のモデルを複数チームで共有 | 密に協力できる少数チーム(乱用注意) |
**腐敗防止層(ACL)**は実務で特に重要です。
from dataclasses import dataclass
# 外部の決済サービスのレスポンス(相手のモデル)
@dataclass(frozen=True)
class LegacyPaymentResponse:
st: int
amt_jpy: str
err_cd: str | None = None
# 腐敗防止層:外部モデル → 自分たちのモデルに変換
class PaymentGatewayAdapter:
def to_payment_result(self, res: LegacyPaymentResponse) -> PaymentResult:
status = PaymentStatus.COMPLETED if res.st == 1 else PaymentStatus.FAILED
return PaymentResult(status, Money.yen(int(res.amt_jpy)))
変換層を挟むことで、外部の「st == 1 は成功」という知識がドメイン層に漏れ出すのを防ぎます。
3.4 マイクロサービスとの関係
「1境界づけられたコンテキスト = 1マイクロサービス」はよい出発点ですが、必須ではありません。モノリスの中でもモジュール(パッケージ)分割でコンテキスト境界は表現できます(モジュラーモノリス)。境界は論理的な概念であり、デプロイ単位とは独立に考えられることを覚えておいてください。
📝 理解度チェック(第3章)
Q3-1. 境界づけられたコンテキストを分けずに、全部門共通の巨大な Product クラスを作った場合に起きる問題を、「変更理由」という言葉を使って説明してください。
Q3-2. 人事システムを考えます。「社員」という言葉が (a) 給与計算 (b) 勤怠管理 (c) 組織図管理 の3つの機能で使われています。それぞれのコンテキストで「社員」が持つべき属性・振る舞いの例を挙げてください。
Q3-3. 外部のレガシーな在庫管理APIと連携する必要があります。APIのレスポンスは {"zaiko_kbn": "A", "su": 5} のような形式です。腐敗防止層(ACL)を設ける利点を説明し、簡単な変換コードのイメージを書いてください。
Q3-4. 「境界づけられたコンテキストごとに必ずマイクロサービスとして分割しなければならない」——この主張の誤りを指摘してください。
▶ 解答例を見る
A3-1. 販売・物流・経理など変更理由の異なる複数部門の要求が1クラスに集まるため、ある部門都合の変更が他部門の機能を壊すリスクが常に生じる。変更理由が違うものは分離すべき(単一責任の原則のモデル版)。
A3-2.(例)(a)給与計算:基本給・手当・控除、calculate_monthly_salary() (b)勤怠管理:所定労働時間・打刻記録、record_clock_in() (c)組織図:所属部署・役職・上長、transfer_to(department)。同じ「社員」でも関心事がまったく異なることが示せていればOK。
A3-3. 利点:外部APIの語彙(zaiko_kbn)や仕様変更がドメイン層に漏れない。API乗り換え時も変換層の修正だけで済む。変換例:
class LegacyInventoryAdapter:
def to_stock(self, res: dict) -> Stock:
status = StockStatus.AVAILABLE if res["zaiko_kbn"] == "A" else StockStatus.UNAVAILABLE
return Stock(status, Quantity.of(res["su"]))
A3-4. 境界づけられたコンテキストは論理的なモデルの境界であり、デプロイ単位(物理)とは独立。モノリス内のモジュール分割でも境界は表現できる(モジュラーモノリス)。分散システム化は運用コストを伴うため、境界の発見とサービス分割の判断は別問題。
第4章 値オブジェクトとエンティティ
ここからは戦術的設計、つまり「モデルをコードでどう表現するか」に入ります。ドメインモデルを構成する最小単位が値オブジェクトとエンティティです。
4.1 値オブジェクト(Value Object)
値オブジェクトは、同一性(ID)を持たず、属性の値そのものによって等価性が決まるオブジェクトです。
例:金額、メールアドレス、氏名、期間、住所、温度……
特徴は3つ:
- 不変(Immutable):一度作ったら変更しない。変更したければ新しいインスタンスを作る
-
値による等価性:
1000円と1000円は同じもの - 自己検証:不正な値では生成できない
Pythonでは @dataclass(frozen=True) が値オブジェクトと非常に相性がよいです。frozen=True で「不変(属性への再代入が例外になる)」と「値による等価性(__eq__ 自動生成)」が同時に得られ、検証だけ __post_init__ に書けば3特徴がそろいます。
from dataclasses import dataclass
from enum import Enum
class Currency(Enum):
JPY = "JPY"
@dataclass(frozen=True) # ① 不変 + ② 値による等価性を標準で得る
class Money:
amount: int
currency: Currency
def __post_init__(self) -> None:
# ③ 生成時に検証(不正な値のインスタンスは存在できない)
if not isinstance(self.amount, int) or self.amount < 0:
raise InvalidMoneyError(self.amount)
@classmethod
def yen(cls, amount: int) -> "Money":
# 業務の言葉で読める生成方法(ファクトリメソッド)
return cls(amount, Currency.JPY)
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatchError()
# ① 自分を書き換えず、新しいインスタンスを返す
return Money(self.amount + other.amount, self.currency)
# 使用例
price = Money.yen(1000)
total = price.add(Money.yen(500)) # Money.yen(1500)
assert Money.yen(1000) == Money.yen(1000) # ② 値が同じなら等しい
# price.amount = 2000 → frozen なので FrozenInstanceError(不変が守られる)
なぜ int のままではダメなのか(プリミティブ執着の問題)
# ❌ プリミティブ執着:price は「円」? 「ドル」? マイナスは許される?
def apply_discount(price: int, rate: float) -> int:
...
# ⭕ 型が業務ルールを守ってくれる
def apply_discount(price: Money, rate: "DiscountRate") -> Money:
...
値オブジェクトを導入すると、「不正な状態がそもそも作れない」「検証ロジックが1箇所に集まる」「引数の取り違えを型チェッカー(mypy等)が検出できる」という効果があります。
4.2 エンティティ(Entity)
エンティティは、同一性(ID)によって識別され、属性が変わっても「同じもの」であり続けるオブジェクトです。
例:ユーザー、注文、商品、口座……
class Member:
def __init__(self, id: "MemberId", name: "MemberName", email: "EmailAddress") -> None:
self._id = id # 同一性の根拠。ID自体も値オブジェクトにする
self._name = name
self._email = email
@property
def id(self) -> "MemberId":
return self._id
def change_email(self, new_email: "EmailAddress") -> None:
self._email = new_email # 属性は変わるが、同じ会員であり続ける
def __eq__(self, other: object) -> bool:
# IDだけで比較する(属性が違っても同一IDなら同じ会員)
return isinstance(other, Member) and self._id == other._id
def __hash__(self) -> int:
return hash(self._id)
エンティティは可変なので
@dataclass(frozen=True)は使いません。__eq__/__hash__をIDだけで実装するのが要点です。
4.3 見分け方:「それが入れ替わっても困らないか?」
| 質問 | Yesなら |
|---|---|
| 属性がすべて同じなら交換可能か?(千円札はどの千円札でも同じ) | 値オブジェクト |
| 属性が変わっても追跡し続けたいか?(改名しても同じ人) | エンティティ |
| ライフサイクル(作成→変更→削除)を管理したいか? | エンティティ |
同じ概念でもコンテキストによって変わる点に注意してください。「座席」は、映画館の予約システムでは「A-12席」を特定するエンティティですが、自由席のイベントでは「座れる場所が1つ」という値かもしれません。
4.4 実務のコツ:まず値オブジェクトから始める
DDDの導入で最も費用対効果が高いのは値オブジェクトです。既存プロジェクトでも str で持っているメールアドレスや電話番号を EmailAddress 型にするところから始められます。エンティティの属性はできる限り値オブジェクトにするのが基本方針です。
📝 理解度チェック(第4章)
Q4-1. 値オブジェクトの3つの特徴(不変・値による等価性・自己検証)を、それぞれ「守らなかった場合に何が困るか」とセットで説明してください。
Q4-2. 次の概念は、値オブジェクトとエンティティのどちらでモデリングすべきですか。理由も答えてください。
(a) 郵便番号 (b) 銀行口座 (c) 商品レビューの星評価(1〜5) (d) 従業員
Q4-3. quantity: int を Quantity という値オブジェクトにリファクタリングしてください。ルール:数量は1以上999以下の整数。加算メソッド add も実装すること。
Q4-4. 「エンティティの __eq__ はすべての属性を比較すべきだ」という主張はなぜ誤りですか。
▶ 解答例を見る
A4-1. ①不変でないと:共有されたインスタンスの書き換えが別の場所に波及する(エイリアシングバグ)。②値による等価性がないと:「1000円と1000円が等しくない」ことになりコレクション操作や比較が壊れる。③自己検証がないと:不正な値(マイナス金額など)がシステム内を流通し、使う側全員が検証する羽目になる。
A4-2. (a)値オブジェクト(同じ番号なら交換可能) (b)エンティティ(残高が変わっても同じ口座。口座番号で識別) (c)値オブジェクト(星4は星4) (d)エンティティ(異動・改名しても同じ従業員)。
A4-3.(解答例)
from dataclasses import dataclass
@dataclass(frozen=True)
class Quantity:
value: int
def __post_init__(self) -> None:
if not isinstance(self.value, int) or not (1 <= self.value <= 999):
raise InvalidQuantityError(self.value)
@classmethod
def of(cls, value: int) -> "Quantity":
return cls(value)
def add(self, other: "Quantity") -> "Quantity":
# 加算結果も __post_init__ の検証を通るので 999 超を防げる
return Quantity(self.value + other.value)
加算結果を Quantity(...) として生成し直すことで、上限検証が必ず働く点が重要。
A4-4. エンティティは属性が変わっても同一であり続けるものだから。メール変更前後の会員は「同じ会員」であるべきで、全属性比較だと別人扱いになる。エンティティの等価性はIDのみで判定する。
第5章 集約
5.1 集約とは
**集約(Aggregate)とは、整合性を保つ必要があるエンティティ・値オブジェクトのまとまりであり、変更とトランザクションの単位です。集約の入り口となるエンティティを集約ルート(Aggregate Root)**と呼びます。
例:「注文」集約
5.2 集約の3つのルール
ルール1:外部から集約内部に直接触らせない(必ずルートを経由する)
# ❌ 内部のOrderLineを直接操作 → 不変条件が守られない
order.lines.append(OrderLine(...))
# ⭕ ルートのメソッド経由。不変条件はルートが守る
class Order:
def __init__(self, id: "OrderId", member_id: "MemberId") -> None:
self._id = id
self._member_id = member_id
self._status = OrderStatus.DRAFT
self._lines: list["OrderLine"] = []
def add_line(self, product_id: "ProductId", quantity: "Quantity",
unit_price: "Money") -> None:
if len(self._lines) >= 20:
raise TooManyOrderLinesError() # 「注文明細は20行まで」という不変条件
if self._status.is_confirmed():
raise OrderAlreadyConfirmedError() # 「確定後は明細変更不可」
self._lines.append(OrderLine(product_id, quantity, unit_price))
def total_amount(self) -> "Money":
total = Money.yen(0)
for line in self._lines:
total = total.add(line.subtotal())
return total
集約が守るべき「常に成り立っていなければならないルール」を**不変条件(Invariant)**と呼びます。上の例では「明細は20行まで」「確定後は変更不可」が不変条件です。
ルール2:他の集約へはIDで参照する
# ❌ 他の集約(Product)のオブジェクトを直接持つ
class OrderLine:
def __init__(self, product: "Product", ...):
self.product = product # Order集約からProduct集約を変更できてしまう
# ⭕ IDで参照する
class OrderLine:
def __init__(self, product_id: "ProductId", ...):
self.product_id = product_id
直接参照を許すと、集約の境界が事実上消え、巨大なオブジェクトグラフを丸ごとロード・ロックすることになります。
ルール3:1トランザクションで変更するのは1集約まで(原則)
このルールは初学者がつまずきやすいので、節を改めて詳しく説明します(5.3)。
5.3 「1トランザクション1集約」と整合性 ── なぜ・どこまで守るのか
DDDでは整合性を2種類に分けて考えます。この区別が集約設計の土台です。
| 種類 | 意味 | 守る範囲 | 守り方 |
|---|---|---|---|
| 即時整合性(強整合性) | 「その瞬間、必ず成り立っていなければならない」 | 1つの集約の内部 | 1トランザクションで原子的に更新 |
| 結果整合性 | 「最終的に(数ミリ秒〜数分後に)成り立てばよい」 | 集約をまたぐ範囲 | イベント等で後追いで揃える(第8章) |
なぜ「1トランザクション=1集約」を原則にするのか。 理由は3つあります。
- 不変条件の境界と一致させるため:集約とは「即時に守るべき不変条件のかたまり」。だから即時整合が必要な範囲=集約=1トランザクション、と3つが一致するのが自然です。逆に言えば、「常に同時に正しくなければならないデータ」だけを同じ集約に入れるのが集約設計の指針になります
- ロック競合とデッドロックを避けるため:1トランザクションで複数集約を更新・ロックすると、同時実行時に競合が増え、ロック順序次第でデッドロックが起きます。1集約に限定すればロック範囲が小さく保てます
- 将来の分割(別サービス化)に耐えるため:集約をまたぐ更新を同一トランザクションに依存させると、その2集約は物理的に同じDB・同じサービスに縛られます。結果整合性で繋いでおけば、後から別サービスに切り出せます
具体例で理解する。 「注文確定時に、会員のポイント残高を増やす」を考えます。
これを1トランザクションで両方更新すると、注文のたびにPointBalanceをロックし、同一会員の同時注文で競合します。しかも**業務的には「ポイント付与は数秒遅れても問題ない」**ことがほとんどです。であれば結果整合性が正解です。
# ❌ 2集約を同一トランザクションで更新(結合が強く、競合と障害に弱い)
def confirm_order(self, order_id, member_id):
with self._uow.begin(): # 1つのトランザクション
order = self._orders.find_by_id(order_id)
order.confirm()
self._orders.save(order)
point = self._points.find_by_member(member_id) # 別集約を同時に更新
point.grant(order.total_amount().percent(1))
self._points.save(point)
# ⭕ 自集約だけを確定し、あとは「起きた事実(イベント)」に委ねる
def confirm_order(self, order_id):
with self._uow.begin(): # このトランザクションはOrder集約だけ
order = self._orders.find_by_id(order_id)
order.confirm() # 内部で OrderConfirmed イベントを記録
self._orders.save(order) # 集約とイベントを一緒に保存(第8章)
# PointBalance集約の更新は、別トランザクションでイベントを受けて行う(結果整合性)
「では、どうしても即時に揃えたいときは?」 その要件が本物かをまず疑ってください。「在庫を1つも超過引当してはいけない」のように本当に一瞬の不整合も許されないなら、対象を同じ集約に入れる、または一意制約・悲観ロックなどDBの仕組みで守る、といった設計にします。多くの「同時に更新したい」は、実は「最終的に揃えばよい(結果整合)」で足りることを、業務側にどこまでの遅延が許容されるか確認して判断します。
5.4 集約を小さく設計する
初学者がやりがちなのは巨大集約です。
**集約の境界を決める基準は、5.3で述べた「本当に同時に・即座に守るべき不変条件はどれか」**です。それ以外は別集約に分け、結果整合性で繋ぎます。
📝 理解度チェック(第5章)
Q5-1. 「即時整合性」と「結果整合性」の違いを説明し、それぞれをどの範囲(集約の内/外)で使うかを述べてください。
Q5-2. 次の設計の問題点を指摘し、修正案を示してください。
class Team: # チーム集約
def __init__(self):
self.members: list[Employee] = [] # 社員エンティティを直接保持
def add_member(self, e: "Employee"):
self.members.append(e)
class Employee: # 社員も独立したライフサイクルを持つ
def raise_salary(self, amount: "Money"):
...
Q5-3. 「注文確定時に在庫を減らす」処理で、Order集約とInventory(在庫)集約を同一トランザクションで両方更新する設計にはどんなリスクがありますか。「1トランザクション1集約」を守る代替案も述べてください。
Q5-4. あなたが集約の境界を決めるとき、最初に確認すべき問いは何ですか。5.3の内容を踏まえて答えてください。
▶ 解答例を見る
A5-1. 即時整合性は「その瞬間に必ず成り立つべき整合性」で、1つの集約の内部を1トランザクションで原子的に更新して守る。結果整合性は「最終的に揃えばよい整合性」で、集約をまたぐ範囲に対し、イベント等で後追いに揃えて守る。集約の内側=即時、外側=結果整合、が基本方針。
A5-2. 問題点:Employeeは独立したライフサイクル(昇給など)を持つエンティティなのに、Team集約が直接保持している。Team経由でEmployeeが変更でき境界が曖昧になり、Teamロード時に全社員をロードする性能問題も生じる。修正:TeamとEmployeeを別集約にし、Teamは member_ids: list[EmployeeId] を持つ。
A5-3. リスク:2集約への同時ロックによる競合・デッドロック、片方の障害で全体が失敗する結合の強さ、将来のサービス分割の障害。代替案:Order集約だけを確定・保存し、OrderConfirmed イベントを記録。在庫集約は別トランザクションでそのイベントを受けて引き当てる(結果整合性)。引当に失敗した場合は注文を取り消す補償処理を用意する。
A5-4. 「本当に一瞬の不整合も許されず、同時・即時に守らなければならない不変条件はどれか?(=業務上どこまでの遅延が許容されるか?)」。即時保証が必要な範囲だけを同一集約にし、残りは結果整合性に回す。
第6章 リポジトリとファクトリ
6.1 リポジトリ(Repository)
リポジトリは、集約の永続化と再構築を担うオブジェクトで、ドメイン層に「まるでコレクションのように」集約を出し入れするインターフェースを提供します。
from abc import ABC, abstractmethod
# ドメイン層:インターフェース(抽象基底クラス)だけを定義。技術に依存しない
class OrderRepository(ABC):
@abstractmethod
def find_by_id(self, id: "OrderId") -> "Order | None": ...
@abstractmethod
def save(self, order: "Order") -> None: ...
@abstractmethod
def next_id(self) -> "OrderId":
"""次に採番する OrderId を返す(採番はリポジトリの責務。6.3参照)"""
...
# インフラ層:実装はRDB / KVS / モック等、自由に差し替え可能
class PostgresOrderRepository(OrderRepository):
def __init__(self, client) -> None:
self._client = client
def find_by_id(self, id: "OrderId") -> "Order | None":
row = self._client.query("SELECT * FROM orders WHERE id = %s", id.value)
return OrderMapper.to_domain(row) if row else None
def save(self, order: "Order") -> None:
...
def next_id(self) -> "OrderId":
return OrderId(str(uuid.uuid4()))
ポイント:
- インターフェースはドメイン層、実装はインフラ層に置く(依存性逆転。第9章で詳述)
-
リポジトリは集約単位で作る。
OrderLineRepositoryは作らない(集約の内部はルート経由でしか触れないため) -
メソッド名もユビキタス言語で。汎用の
find(sql)ではなくfind_unshipped_orders_before(date)のように意図を表す - テスト時はインメモリ実装に差し替えられるため、ドメイン層のテストがDBなしで書ける
6.2 ファクトリ(Factory)
ファクトリは、複雑な生成ロジックをカプセル化するパターンです。生成時に守るべきルールが多い場合、__init__ に全部書くと肥大化します。
class OrderFactory:
def __init__(self, order_repository: OrderRepository,
member_repository: "MemberRepository") -> None:
self._orders = order_repository
self._members = member_repository
def create_from_cart(self, cart: "Cart", member_id: "MemberId") -> "Order":
member = self._members.find_by_id(member_id)
if member is None:
raise MemberNotFoundError(member_id)
if cart.is_empty():
raise EmptyCartError()
order = Order(self._orders.next_id(), member_id) # ★採番はリポジトリに一本化
for item in cart.items():
order.add_line(item.product_id, item.quantity, item.price_at_added_time)
return order
簡単な生成なら Order(...) の直接生成や @classmethod の create で十分です。「生成に他の集約の情報や採番が必要」「生成パターンが複数ある」場合にファクトリクラスを検討します。
6.3 【方針統一】IDの採番はリポジトリの責務に一本化する
ID(OrderId など)をどこで採番するかは方針を1つに決めておかないと、OrderId.generate() を呼ぶ場所と repository.next_id() を呼ぶ場所が混在し、混乱のもとになります。本教材では次の方針で統一します。
方針:IDの採番は必ずリポジトリの
next_id()を通す。ドメインオブジェクトやアプリケーション層が独自にuuidを生成したりDBシーケンスを叩いたりしない。
理由:
- 採番は「永続化の都合」(UUIDにするか、DBのシーケンスか、連番か)であり、技術的関心事。これをドメインの中心に散らばらせない
- 採番方式を変えても(UUID → ULID → DB連番)、修正箇所がリポジトリ実装1箇所で済む
- テスト時は「常に固定IDを返す
next_id()」のインメモリ実装に差し替えられ、テストが決定的になる
# ⭕ 統一された使い方(採番はすべて next_id 経由)
order_id = order_repository.next_id() # アプリケーション層/ファクトリ
order = Order(order_id, member_id)
# ❌ 混在させない(この教材では使わない)
order = Order(OrderId.generate(), member_id) # ドメインが採番方式を知ってしまう
order = Order(OrderId(str(uuid.uuid4())), ...) # アプリ層が採番方式を知ってしまう
補足:UUIDのように「衝突しないIDをアプリ側で自由に生成してよい」と割り切る流派もあります。その場合でも「採番の入口は1つ(
next_id)」に集約しておけば、next_idの中身をUUID生成にするだけで方針は保てます。大事なのは採番の窓口を一本化することです。
6.4 リポジトリに入れてはいけないもの
- ドメインロジック:「キャンセル可能な注文か」の判定はOrder側に置く。リポジトリはあくまで出し入れ係
- 画面都合の検索クエリ:一覧画面用の複雑なJOINや集計は、ドメインモデルを経由せず、参照専用のクエリサービスで直接DTOを返す設計(CQRSの読み書き分離)がよく使われます。「更新はドメインモデル経由、参照は最短距離で」と覚えてください
📝 理解度チェック(第6章)
Q6-1. リポジトリのインターフェースをドメイン層に、実装をインフラ層に分けることで得られるメリットを、テストの観点を含めて2つ挙げてください。
Q6-2. OrderLineRepository(注文明細だけを保存・取得するリポジトリ)を作るべきでない理由を、集約のルールから説明してください。
Q6-3. 管理画面に「会員名・注文合計・最終注文日を一覧表示する」機能を追加します。この検索処理を OrderRepository に追加すべきでしょうか。あなたの考えと理由を述べてください。
Q6-4. 本教材では「IDの採番」をどこに一本化しましたか。またその方針にすると、採番方式をUUIDからDB連番へ変更するとき修正箇所はどうなりますか。
▶ 解答例を見る
A6-1. ①ドメイン層が特定のDB技術に依存せず、DB変更やライブラリ更新の影響が局所化される ②テスト時にインメモリ実装へ差し替えられ、DBなしで高速・安定したユニットテストが書ける。
A6-2. OrderLineはOrder集約の内部要素であり、集約の外から直接出し入れすると、Orderルートが守るべき不変条件(明細行数上限、確定後変更不可など)を迂回できてしまうため。リポジトリは集約ルート単位で作る。
A6-3.(推奨解)追加すべきでない。会員と注文をまたぐ画面都合の集計であり、ドメインモデル(集約)の再構築を経由する必然性がない。参照専用のクエリサービスがDTOを直接返す構成(読み書き分離)にする。ただし小規模ならリポジトリに置く判断もあり得る——トレードオフを述べられていれば良い。
A6-4. リポジトリの next_id() に一本化した。採番方式を変えても修正はリポジトリ実装の next_id() 1箇所で済み、ドメイン層・アプリケーション層・呼び出し側は一切変更不要になる。
第7章 ドメインサービスとアプリケーションサービス
7.1 ドメインサービス(Domain Service)
エンティティにも値オブジェクトにも自然に置けないドメインロジックを担うのがドメインサービスです。
典型例:「メールアドレスの重複チェック」
# ❌ Member自身に持たせると不自然:「自分が重複しているか」を自分に聞く?
member.is_duplicated() # 全会員の知識をMemberが持つことになる
# ⭕ ドメインサービスに置く
class MemberDuplicationChecker:
def __init__(self, member_repository: "MemberRepository") -> None:
self._members = member_repository
def is_duplicated(self, email: "EmailAddress") -> bool:
return self._members.find_by_email(email) is not None
他の例:複数集約にまたがる計算(送金:2つの口座集約の残高操作のルール)、外部知識が必要な判定など。
⚠️ 最重要の注意:ドメインサービスは最後の手段です。何でもドメインサービスに置くと、エンティティが振る舞いを失い、ただのデータ入れ物になります(貧血モデル。第11章)。「まずエンティティか値オブジェクトに置けないか」を必ず先に検討してください。
7.2 アプリケーションサービス(Application Service)
アプリケーションサービスは、ユースケース(利用者から見た1つの操作)を実現するために、ドメインオブジェクトを組み合わせて指揮する層です。ドメインロジックは持ちません。
class CancelOrderUseCase:
def __init__(self, order_repository: "OrderRepository", clock: "Clock") -> None:
self._orders = order_repository
self._clock = clock
def execute(self, command: "CancelOrderCommand") -> None:
# 1. 集約を取得
order = self._orders.find_by_id(command.order_id)
if order is None:
raise OrderNotFoundError(command.order_id)
# 2. ドメインの振る舞いを呼ぶ(判断はOrderがする)
order.cancel(self._clock.now())
# 3. 保存
self._orders.save(order)
7.3 2つのサービスの違い(混同注意)
| ドメインサービス | アプリケーションサービス | |
|---|---|---|
| 層 | ドメイン層 | アプリケーション層 |
| 責務 | ドメインの知識・ルールそのもの | ユースケースの進行(取得→実行→保存、トランザクション、認可、通知) |
| 例 | 重複チェック、送金ルール | 「注文をキャンセルする」一連の流れ |
| ドメインロジックを持つか | 持つ | 持たない(持ち始めたら黄色信号) |
見分けるリトマス試験紙:「その処理は業務ルールか、それとも手順か?」——ルールならドメイン、手順ならアプリケーションです。
📝 理解度チェック(第7章)
Q7-1. 「メールアドレスの重複チェック」をMemberエンティティではなくドメインサービスに置く理由を説明してください。
Q7-2. 「ドメインサービスは最後の手段」と言われるのはなぜですか。乱用した場合に何が起きるかを含めて答えてください。
Q7-3. 次の処理を「ドメインサービスに置くべきもの」「アプリケーションサービスに置くべきもの」「エンティティ/値オブジェクトに置くべきもの」に分類してください。
(a) 注文合計金額の計算
(b) 注文確定後に確認メールを送る手配
(c) 振込元・振込先2口座の残高を検証して送金可否を判断する
(d) 注文キャンセル処理全体のトランザクション制御
Q7-4. アプリケーションサービスに if order.status == "SHIPPED": raise ... のような判定が書かれていました。これはなぜ問題で、どう直すべきですか。
▶ 解答例を見る
A7-1. 重複チェックには「全会員の中に同じメールが存在するか」という自分以外の知識が必要で、Member単体には自然に持たせられないため(member.is_duplicated() は不自然)。リポジトリを使って全体を照会するロジックとしてドメインサービスに置く。
A7-2. ドメインサービスは何でも置けてしまうため、乱用するとエンティティから振る舞いが流出し、貧血モデル+手続き型サービスの構造に逆戻りするから。まずエンティティ・値オブジェクトに置けないか検討し、置けない場合のみ使う。
A7-3. (a)エンティティ(Order自身の責務) (b)アプリケーションサービス(または OrderConfirmed イベントのハンドラ。手順・手配であってルールではない) (c)ドメインサービス(2つの口座集約にまたがるルール) (d)アプリケーションサービス。
A7-4. 「発送済みならキャンセル不可」という業務ルールがドメインの外に漏れており、他のユースケースに同じ判定が複製される。order.cancel() の内部で判定させ、アプリケーションサービスは呼ぶだけにする(Tell, Don't Ask)。
第8章 ドメインイベント
8.1 ドメインイベントとは
ドメインイベントは、ドメインで起きた「出来事」を表すオブジェクトです。過去形で命名します(OrderConfirmed、MemberRegistered、PaymentFailed)。値オブジェクトと同様、不変にするのが基本なので @dataclass(frozen=True) が向いています。
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class OrderConfirmed:
order_id: "OrderId"
member_id: "MemberId"
total_amount: "Money"
occurred_at: datetime
8.2 何がうれしいのか:関心事の分離
「注文確定時に、①在庫を引き当て、②確認メールを送り、③ポイントを付与する」という要件を考えます。
# ❌ ユースケースが全部を知っている(追加要件のたびにここが太る)
class ConfirmOrderUseCase:
def execute(self, cmd):
order.confirm()
self._orders.save(order)
self._inventory.allocate(order) # ①
self._mail.send_confirmation(order) # ②
self._points.grant(order) # ③
# ⭕ Orderは「確定した」という事実だけを記録。後続処理は購読側の責任
class Order:
def confirm(self) -> None:
self._status = OrderStatus.CONFIRMED
self._record_event(OrderConfirmed( # ← 「記録」(8.3参照)
self._id, self._member_id, self.total_amount(), datetime.now()))
購読側(それぞれ独立したハンドラ):
class AllocateInventoryOnOrderConfirmed:
def handle(self, event: OrderConfirmed) -> None: ...
class SendMailOnOrderConfirmed:
def handle(self, event: OrderConfirmed) -> None: ...
class GrantPointsOnOrderConfirmed:
def handle(self, event: OrderConfirmed) -> None: ...
「ポイント付与の仕様変更」が注文のコードに影響しなくなります。
8.3 イベントは「記録 → 保存 → 配信」の3段階で扱う
初学者が「イベントを発行する(publish)」を1つの動作だと思い込むと、整合性の事故を起こします。実務では記録・保存・配信の3段階に分けて理解することが重要です。それぞれ「いつ・どの層で・どのトランザクションで」起きるかが違います。
| 段階 | 何をするか | 場所・タイミング | トランザクションとの関係 |
|---|---|---|---|
| ① 記録(record) | 集約が「この出来事が起きた」を自分の内部リストに溜めるだけ。まだ誰にも通知しない | ドメイン層。集約のメソッド内(order.confirm() の中) |
まだDBには触れない。純粋なメモリ操作 |
| ② 保存(persist) | 溜まったイベントを、業務データと同じDBトランザクションで専用テーブル(Outbox)に書き込む | インフラ層。リポジトリの save() の中 |
集約の更新と同一トランザクション。ここが整合性の要 |
| ③ 配信(distribute) | 保存済みのイベントを取り出し、購読ハンドラへ届ける(別トランザクション/別プロセスでもよい) | インフラ層。ディスパッチャ/ワーカー | 集約の更新とは別トランザクション。失敗したらリトライ |
なぜ「記録」と「配信」を分けるのか ── 二重書き込み問題
もし集約の更新(DB)とイベントの配信(メッセージブローカー)を別々に行うと、次の事故が起きます。
DB更新は成功 → 直後にプロセスが落ちる → イベント配信されず → ポイントが永久に付与されない
(逆に、配信は成功したがDB更新がロールバック → 存在しない注文のポイントが付く)
これを**二重書き込み問題(dual write problem)**と呼びます。「DBへの書き込み」と「ブローカーへの書き込み」という2つの外部への書き込みを、1つのトランザクションで原子的にできないために起きます。
解決:Transactional Outboxパターン
「②保存」で、イベントを業務データと同じDBトランザクションでOutboxテーブルに書き込みます。DBトランザクションは原子的なので「注文は確定したのにイベントは記録されていない(またはその逆)」が起きません。「③配信」はその後、Outboxテーブルをポーリングするワーカーが担当し、配信済みフラグを立てます。配信が失敗してもレコードは残るのでリトライできます。
# ① 記録:集約が内部にイベントを溜める(第8.2のOrder.confirmを参照)
class Order:
def __init__(self, ...):
self._events: list[object] = []
def _record_event(self, event: object) -> None:
self._events.append(event)
def pull_events(self) -> list[object]:
events, self._events = self._events, [] # 取り出して内部を空にする
return events
# ② 保存:リポジトリが「集約」と「イベント」を同一トランザクションで書く
class PostgresOrderRepository(OrderRepository):
def save(self, order: Order) -> None:
with self._db.begin(): # ← 1つのDBトランザクション
self._db.upsert_order(OrderMapper.to_row(order))
for event in order.pull_events(): # 業務データと同じTxでOutboxへ
self._db.insert_outbox(EventMapper.to_row(event))
# commit された時点で「注文の確定」と「イベントの記録」は必ず一致している
# ③ 配信:別プロセスのワーカーがOutboxを読み、購読側へ届ける(別トランザクション)
class OutboxDispatcher:
def dispatch_pending(self) -> None:
for row in self._db.fetch_undelivered_outbox():
event = EventMapper.to_domain(row)
self._bus.publish(event) # 各ハンドラへ
self._db.mark_delivered(row.id) # 失敗時は未配信のまま→次回リトライ
この3段階を押さえると、第5章の「集約をまたぐ整合性は結果整合性で守る」が具体的にどう実現されるかが繋がります。①②で「起きた事実」を確実に残し、③で他集約へ伝えて最終的に整合させる——これが結果整合性の実装の骨格です。
8.4 導入の目安
イベントは強力ですが、処理の流れが追いにくくなるコストがあります。目安:
- 後続処理が失敗しても主処理を巻き戻す必要がない(メール送信失敗で注文自体は取り消さない)→ イベント向き
- 主処理と同時に必ず成立すべき → 同一集約にできないか再検討(第5章)
📝 理解度チェック(第8章)
Q8-1. ドメインイベントを過去形で命名するのはなぜですか。
Q8-2. イベントの扱いを「記録・保存・配信」の3段階に分けたとき、整合性を守るうえで最も重要なのはどの段階と、どの段階を同一トランザクションにすることですか。理由も述べてください。
Q8-3. 「DB更新は成功したがイベント配信の直前にプロセスが落ちて、後続処理が永久に実行されない」——この問題を何と呼び、どのパターンでどう防ぎますか。「記録・保存・配信」の言葉を使って説明してください。
Q8-4. 「会員登録時にウェルカムメールを送る」機能で、メール送信が失敗した場合に会員登録自体を失敗させるべきでしょうか。あなたの判断と、それを「記録・保存・配信」でどう実現するか説明してください。
▶ 解答例を見る
A8-1. イベントは「すでに起きた変更不可能な事実」を表すため。過去形にすることで、命令(これからやること)との混同を防ぎ、受け手が拒否できない確定した出来事であることが明確になる。
A8-2. 最も重要なのは②保存であり、①記録した集約の更新と②イベントのOutboxへの保存を同一DBトランザクションにすること。両者が原子的にコミット/ロールバックされることで、「注文は確定したのにイベントが無い(またはその逆)」という不整合が原理的に起きなくなる。③配信は別トランザクションでよく、失敗してもリトライで回復できる。
A8-3. 二重書き込み問題(dual write problem)。Transactional Outboxパターンで防ぐ。①集約がイベントを内部に記録し、②リポジトリのsaveで集約の更新とイベントのOutbox保存を同一トランザクションにする(ここで確実に残る)。③別プロセスのディスパッチャがOutboxを読んで配信し、配信済みにする。プロセスが落ちてもOutboxにレコードが残っているため、再起動後に③が再実行され後続処理が失われない。
A8-4.(推奨解)失敗させるべきでない。メール送信は登録の成立条件ではなく、後から再送も可能なため。実現:会員登録トランザクションでは MemberRegistered を①記録し、集約更新と同一トランザクションで②保存する(ここまでで登録は確定)。メール送信は③配信でハンドラが担当し、失敗時はリトライする。こうすればメール障害が会員登録をロールバックさせない。
第9章 DDDとアーキテクチャ
9.1 なぜアーキテクチャの話が必要か
戦術的パターンをどれだけ丁寧に作っても、ドメイン層がフレームワークやDBに依存していたら台無しです。ドメインモデルを技術的関心事から隔離するための構造がアーキテクチャです。
9.2 レイヤードアーキテクチャと依存性逆転
古典的なレイヤードアーキテクチャでは「ドメイン層 → インフラ層」と依存が下向きでした。現代のDDDでは**依存性逆転の原則(DIP)**を使い、すべての依存をドメイン層に向けます。
(矢印はすべて依存の向き。プレゼン・アプリ・インフラのいずれもドメイン層に依存し、ドメイン層は何にも依存しない=依存が内向きに集まる)
Pythonでは、リポジトリの抽象を abc.ABC(または typing.Protocol)でドメイン層に定義し、その具象実装をインフラ層に置くことで依存性逆転を表現します。オニオンアーキテクチャ、ヘキサゴナルアーキテクチャ(ポート&アダプター)、クリーンアーキテクチャは、いずれも表現は違えど**「ドメインを中心に置き、依存を内向きにする」**という同じ思想の変奏です。
9.3 典型的なディレクトリ構成
src/
├── domain/ # ドメイン層(何にも依存しない)
│ ├── order/
│ │ ├── order.py # 集約ルート
│ │ ├── order_id.py # 値オブジェクト
│ │ ├── order_status.py
│ │ ├── order_repository.py # 抽象基底クラス(ABC)のみ
│ │ └── events.py # OrderConfirmed などのドメインイベント
│ └── member/
├── application/ # アプリケーション層
│ └── order/
│ ├── confirm_order.py # ConfirmOrderUseCase
│ └── cancel_order.py # CancelOrderUseCase
├── infrastructure/ # インフラ層
│ └── persistence/
│ └── postgres_order_repository.py # 具象実装
└── presentation/ # プレゼンテーション層
└── order_controller.py
チェックポイント:domain/ パッケージの中に、Web/DBフレームワーク(FastAPI、SQLAlchemy、Django等)の import 文が1つもないこと。これが守られていれば、ドメイン層のユニットテストは外部環境なしで高速に実行できます。
9.4 ドメイン層を汚染から守る実践ルール
- ドメイン層でORM(SQLAlchemyのモデル基底クラス等)を直接継承しない。DBとのマッピングはインフラ層に寄せる
- HTTPリクエスト/レスポンスやフレームワークの型をドメイン層に持ち込まない(DTOはプレゼンテーション/アプリケーション層で変換)
-
datetime.now()や乱数の直接呼び出しを避け、Clockなどの抽象(ABC/Protocol)経由にする(テスト容易性)
from abc import ABC, abstractmethod
from datetime import datetime
class Clock(ABC):
@abstractmethod
def now(self) -> datetime: ...
class SystemClock(Clock): # 本番用(インフラ層)
def now(self) -> datetime:
return datetime.now()
class FixedClock(Clock): # テスト用:時刻を固定できる
def __init__(self, fixed: datetime) -> None:
self._fixed = fixed
def now(self) -> datetime:
return self._fixed
📝 理解度チェック(第9章)
Q9-1. 「依存性逆転」によって、リポジトリの抽象(ABC)と実装はそれぞれどの層に置かれますか。また「逆転」しているのは何と何の関係ですか。
Q9-2. ドメイン層のクラスがORM(例:SQLAlchemyのモデル)を直接継承することの問題点を2つ挙げてください。
Q9-3. オニオン、ヘキサゴナル、クリーンアーキテクチャに共通する中心思想を1文で述べてください。
Q9-4. ドメイン層で datetime.now() を直接呼ばず Clock 抽象を経由させると、どんな利点がありますか。
▶ 解答例を見る
A9-1. 抽象(ABC)はドメイン層、具象実装はインフラ層。従来「ドメイン→インフラ」だった依存の向きが、「インフラ(実装)→ドメイン(抽象)」に逆転している。上位モジュールが下位の詳細に依存せず、両者が抽象に依存する形。
A9-2. ①ドメイン層のテストにDB/ORMのセットアップが必要になり、テストが遅く不安定になる ②ORMの都合(特定の基底クラスの継承、宣言的マッピング、遅延ロードの挙動など)がモデル設計を歪める。加えてORM乗り換え時の影響が全域に及ぶ。
A9-3. ドメイン(ビジネスルール)を中心に置き、すべての依存を外側から内側(ドメイン)へ向け、技術的詳細を外側に追いやる。
A9-4. テスト時に FixedClock で時刻を固定でき、「確定から7日でキャンセル不可」のような時間依存のロジックを決定的にテストできる。ドメイン層が実時間(副作用)に直接依存しなくなる。
第10章 実践演習:ミニECサイトの設計
これまでの知識を総動員して、小さなECドメインを設計します。まず自分で解いてから解説を読んでください。
10.1 要件
図書のオンライン販売サイト「BookShelf」を作ります。
- 会員はカートに書籍を入れ、注文を確定できる
- 注文明細は最大10行。同じ書籍はカート内で1行にまとめ、数量は1〜99
- 注文確定時の合計金額が5,000円以上なら送料無料、未満なら送料500円
- 注文は「未確定 → 確定 → 発送済 → 配達完了」と遷移する。キャンセルは確定後・発送前のみ可能
- 注文確定時、会員に購入金額の1%のポイントを付与する(付与が遅れても業務上問題ない)
- 書籍の価格は日々変わるが、注文金額は「確定時点の価格」で固定される
10.2 演習課題
- 課題A:登場する値オブジェクト・エンティティ・集約を洗い出し、集約の境界を図示してください
- 課題B:要件4のステータス遷移をコードで表現してください
- 課題C:要件5を、第5章「1トランザクション1集約」と第8章「記録・保存・配信」を踏まえて実現してください
- 課題D:要件6は設計にどう影響しますか
10.3 解説
課題A:集約の設計
判断のポイント:
- 「明細は最大10行」「合計5,000円で送料無料」はOrder集約内で即時に守るべき不変条件 → OrderLineはOrder集約の内部
- ポイント付与は遅延OK(要件5)→ Pointは別集約にし、結果整合性で更新
- OrderはMemberやBookをIDで参照する
課題B:ステータス遷移
from enum import Enum
class OrderStatusValue(str, Enum):
DRAFT = "DRAFT"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELED = "CANCELED"
# 許可された遷移だけを定義(表にないものは不正遷移)
_ALLOWED = {
OrderStatusValue.DRAFT: {OrderStatusValue.CONFIRMED},
OrderStatusValue.CONFIRMED: {OrderStatusValue.SHIPPED, OrderStatusValue.CANCELED},
OrderStatusValue.SHIPPED: {OrderStatusValue.DELIVERED},
}
@dataclass(frozen=True)
class OrderStatus:
value: OrderStatusValue
def transition_to(self, nxt: "OrderStatus") -> "OrderStatus":
if nxt.value not in _ALLOWED.get(self.value, set()):
raise InvalidStatusTransitionError(self.value, nxt.value)
return nxt
def is_cancelable(self) -> bool:
return self.value == OrderStatusValue.CONFIRMED # 確定後・発送前のみ
def is_confirmed(self) -> bool:
return self.value == OrderStatusValue.CONFIRMED
class Order:
def confirm(self) -> None:
self._status = self._status.transition_to(OrderStatus(OrderStatusValue.CONFIRMED))
self._record_event(OrderConfirmed( # ← 記録(第8章①)
self._id, self._member_id, self.total_with_shipping(), datetime.now()))
def cancel(self) -> None:
if not self._status.is_cancelable(): # 確定後・発送前のみ
raise OrderNotCancelableError(self._id)
self._status = OrderStatus(OrderStatusValue.CANCELED)
遷移ルールを値オブジェクト(OrderStatus)に集約することで、「どこかで status を直接書き換えて不正遷移」という事故を防ぎます。
課題C:ポイント付与 ── 整合性の設計を明示する
まず整合性の種類を判定します。要件5に「付与が遅れても業務上問題ない」とあるので、これは即時整合性ではなく結果整合性でよいと判断できます(第5章5.3)。したがって Order集約と PointBalance集約を同一トランザクションで更新してはいけません(「1トランザクション1集約」)。
実装は第8章の「記録・保存・配信」に沿います。
(Tx1で「注文の確定」と「イベントの記録」が同一トランザクションで確実に残り、Tx2でそれを受けて別トランザクションでポイントを更新する=結果整合性)
class GrantPointsOnOrderConfirmed:
def __init__(self, point_repository: "PointBalanceRepository") -> None:
self._points = point_repository
def handle(self, event: OrderConfirmed) -> None:
# これは Tx1 とは別トランザクション。失敗してもリトライで回復できる
balance = self._points.find_by_member(event.member_id)
balance.grant(event.total_amount.percent(1)) # 購入金額の1%
self._points.save(balance)
こうすることで、①ポイント付与処理が一時的に失敗しても注文確定は取り消されない ②同一会員の同時注文でPointBalanceのロック競合が注文確定をブロックしない ③将来ポイントを別サービスに切り出せる、という第5章で述べた3つの利点が得られます。
課題D:確定時価格の固定
OrderLine が BookId だけでなく**unit_price(確定時点の価格のコピー)を値として保持する**ことが答えです。「Bookの現在価格を参照すればいい」という設計だと、価格改定のたびに過去の注文金額が変わってしまいます。これは「他集約をID参照する」設計の自然な帰結でもあります:必要なデータはその時点の値としてコピーして持つのです。
@dataclass(frozen=True)
class OrderLine:
book_id: "BookId" # 他集約はIDで参照
quantity: "Quantity"
unit_price: "Money" # 確定時点の価格を値としてコピー保持
def subtotal(self) -> "Money":
return self.unit_price.multiply(self.quantity.value)
📝 理解度チェック(第10章)
Q10-1. 課題Aで、OrderLineを独立した集約ではなくOrder集約の内部に置いた根拠となる要件はどれですか。「即時整合性」の言葉を使って説明してください。
Q10-2. 「注文確定と同時にポイント残高も必ず増えていなければならない」と要件が変わった場合、設計はどう見直すべきですか。第5章5.3を踏まえ、検討の観点を述べてください。
Q10-3. 要件6(確定時価格の固定)を無視して OrderLine が Book への参照だけを持つ設計にした場合、どんな不具合が起きますか。具体的なシナリオで説明してください。
Q10-4. 課題Cで、ポイント付与を Order確定と同一トランザクションにしなかった理由を、「整合性の種類」と「記録・保存・配信」の両方の観点から説明してください。
▶ 解答例を見る
A10-1. 要件2(明細は最大10行・同一書籍は1行・数量1〜99)と要件3(合計金額による送料判定)。これらは「注文が保存されるその瞬間に必ず成り立っていなければならない」即時整合性の対象であり、明細を別集約に出すと1トランザクションで守れなくなる。よってOrder集約の内部に置く。
A10-2. 観点:①まず本当に即時整合が必要か業務側に再確認する(「画面反映が数秒遅れてよいか」等)。②本当に必須なら、OrderとPointBalanceを同一集約にまとめられないか、あるいは同一トランザクションで更新するコスト(ロック競合・結合の強さ・将来分割の困難さ)を受け入れるかを比較する。安易に「同一トランザクションにする」で終えず、整合性の種類の判定と要件の再確認から入れていれば良い。
A10-3. 例:1月に1,000円で購入確定した書籍が2月に1,500円へ値上げされると、注文詳細画面・請求金額・返金額がすべて1,500円ベースに化ける。過去の事実(確定時点の価格)が現在のマスタ価格に引きずられて改変される不具合。unit_price を値としてコピー保持していれば防げる。
A10-4. 整合性の種類:要件5より付与は遅延可=結果整合性でよいので、集約をまたぐ即時更新(同一トランザクション)は不要かつ有害(ロック競合・結合)。記録・保存・配信:Order確定時に OrderConfirmed を①記録し、②集約更新と同一トランザクションでOutboxに保存して「事実」を確実に残す。ポイント更新は③配信で別トランザクションのハンドラが行い、失敗してもリトライで回復する。これにより注文確定とポイント付与が疎結合になる。
第11章 よくあるアンチパターンと誤解
11.1 貧血ドメインモデル(Anemic Domain Model)
最頻出のアンチパターン。エンティティがgetter/setterだけのデータ入れ物になり、ロジックが全部サービスに書かれている状態です。
# ❌ 貧血モデル:クラスは分かれているが、実態はトランザクションスクリプト
class Order:
def get_status(self): ...
def set_status(self, s): ...
def get_lines(self): ...
def set_lines(self, l): ...
class OrderService:
def cancel(self, order: Order) -> None:
if order.get_status() == "SHIPPED": # ルールが外にある
raise ...
order.set_status("CANCELED")
兆候:setterが多い / サービスクラスが肥大化 / if x.get_status() == ... が複数箇所にある
処方箋:「Tell, Don't Ask」——状態を聞いて外で判断するのではなく、オブジェクトに命じる(order.cancel())
11.2 「DDD=戦術パターンの適用」という誤解
値オブジェクトやリポジトリを導入しただけでは「DDDをやっている」とは言えません(「軽量DDD」と揶揄されます)。ドメインエキスパートとの対話・ユビキタス言語・境界づけられたコンテキストという戦略面を欠くと、複雑なだけの構造が残ります。逆に、戦術パターンを完全には使わなくても、業務の言葉でモデルを作りエキスパートと会話できていれば、DDDの価値の大半は得られています。
11.3 全部に集約・全部にDDD
CRUDで十分な管理系機能にまでフル装備の集約・リポジトリ・ユースケースを適用すると、コード量だけが膨らみます。コンテキストごとに投資量を変えてよいのです(中核ドメインには厚く、支援的なドメインには薄く)。
11.4 その他の落とし穴チェックリスト
- 集約が巨大化していないか(ロードが遅い、ロック競合が多いのは兆候)
- 「1トランザクション1集約」を破って複数集約を1トランザクションで更新していないか(第5章)
- イベントの「保存」と「配信」を分けず、二重書き込み問題を放置していないか(第8章)
- リポジトリが画面都合のメソッドだらけになっていないか(→参照系の分離を検討)
- ドメインサービスが増殖していないか(→エンティティに振る舞いを戻せないか)
-
値オブジェクトを作ったのに
.valueで中身を取り出してロジックを外に書いていないか -
ID採番の窓口が
next_id()以外にも散らばっていないか(第6章) - ユビキタス言語の用語集が更新されているか(初期に作って放置は形骸化のサイン)
📝 理解度チェック(第11章)
Q11-1. 貧血ドメインモデルを見分ける「兆候」を2つ挙げ、それぞれなぜ問題なのか説明してください。
Q11-2. 「Tell, Don't Ask」の原則を、Pythonのコード例を1つ作って説明してください。
Q11-3. 「軽量DDD」とは何を指し、何が欠けていると言われているのですか。
Q11-4. 同僚が「全機能をDDDのフル装備(集約・リポジトリ・ユースケース・イベント)で作るべきだ」と主張しています。あなたはどう応えますか。
▶ 解答例を見る
A11-1. ①setterだらけのエンティティ:不変条件を守る場所がなく、どこからでも不正な状態にできる ②if x.get_status() == ... の散在:同じ業務ルールが複数箇所に複製され、仕様変更時に修正漏れが起きる。(サービスクラスの肥大化なども可)
A11-2.(例)
# Ask(状態を聞いて外で判断)❌
if account.balance().is_greater_than(amount):
account.set_balance(account.balance().subtract(amount))
# Tell(命じる。判断は本人がする)⭕
account.withdraw(amount) # 残高不足なら内部で例外を送出
A11-3. 戦略的設計(ユビキタス言語、境界づけられたコンテキスト、エキスパートとの協働)を欠いたまま、値オブジェクト・リポジトリ等の戦術パターンだけを形式的に適用すること。ドメインへの深い理解というDDDの目的が抜け落ちている。
A11-4.(解答例)コンテキスト/機能の複雑さに応じて投資量を変えるべきと応える。中核ドメインにはフル装備が正当化されるが、単純なCRUD機能に適用するとコード量と学習コストだけ増える。「複雑さがどこにあるか」で判断し、支援的な機能はシンプルな構成でよい、というトレードオフを説明できれば良い。
第12章 次のステップと参考文献
12.1 学習ロードマップ
-
今すぐ:担当プロジェクトでプリミティブ型を1つ値オブジェクト(
@dataclass(frozen=True))にしてみる/用語のズレを1つ見つけて用語集を書き始める - 1〜3ヶ月:新機能1つをユースケース+集約+リポジトリの構成で書いてみる。レビューで「このルールはどこに置くべきか」を議論する
- 3〜6ヶ月:ドメインエキスパートとのモデリングセッション(イベントストーミング等)に挑戦する。境界づけられたコンテキストの観点で自システムのマップを描いてみる
12.2 参考文献
- エリック・エヴァンスのドメイン駆動設計(Eric Evans)— 原典。通称「エヴァンス本」。難解なので2冊目以降でも可
- 実践ドメイン駆動設計(Vaughn Vernon)— 通称「IDDD本」。実装寄りで具体的。集約設計やイベントの章が本教材の補強に最適
- ドメイン駆動設計入門(成瀬允宣)— 日本語で書かれた戦術パターンの入門として最適。本教材の次の1冊におすすめ
- ドメイン駆動設計 モデリング/実装ガイド(松岡幸一郎)— 戦略と戦術のバランスがよいコンパクトな解説
- Learning Domain-Driven Design(Vlad Khononov)— 戦略的設計とアーキテクチャ選択の判断基準が明快
12.3 最後に
DDDは「覚えるもの」ではなく「実践しながら精度を上げるもの」です。完璧なモデルは最初から作れません。モデルは仮説であり、実装とエキスパートとの会話を通じて改善し続けるもの——この姿勢こそがDDDの核心です。
本教材はここまでです。理解度チェックで詰まった章は、本文を読み直すより「自分のプロジェクトの題材で同じ問いを立て直す」ほうが定着します。よい設計を!