ドメイン駆動設計 — パターンとプラクティス
DDDをいつ使うか
使用すべき場合:
- CRUDを超える複雑なビジネスロジックがある場合
- 異なる用語を持つ複数のドメインエキスパートが存在する場合
- ドメインモデルが競争上の優位性となる場合
- エンタープライズグレードのアプリケーション
使用すべきでない場合:
- 単純なCRUDアプリケーション
- ハッカソン/MVP(過剰設計)
- ドメインエキスパートが不在の場合
戦略的設計
境界付けられたコンテキスト
DDDで最も重要な概念。境界付けられたコンテキストとは、特定のモデルが定義され、適用可能な境界のことです。
例 — 電子商取引:
[注文コンテキスト] [支払いコンテキスト] [配送コンテキスト]
- 注文 - 支払い - 配送
- 注文明細 - 取引 - 追跡番号
- 顧客 (参照) - 返金 - 住所
- 住所 (値オブジェクト) - 請求書 - 運送会社
顧客は各コンテキストで異なる意味を持ちます:
- 注文コンテキスト: 名前、メール、配送設定
- 支払いコンテキスト: 請求先情報、支払い方法
- サポートコンテキスト: チケット履歴、満足度スコア
コンテキストマップ
[注文] ←→ [支払い] # パートナーシップ
[注文] → [配送] # 顧客-供給者
[注文] → [レガシーCRM] # アンチコラプションレイヤー
戦術的設計
エンティティ
同一性を持つオブジェクト。同じ属性でも異なるIDを持つ2つのエンティティは異なるものとして扱われます。
class User {
readonly id: UserId;
name: string;
email: Email; // 値オブジェクト
}
値オブジェクト
属性によって定義され、同一性を持たないオブジェクト。不変です。
class Email {
constructor(readonly value: string) {
if (!isValidEmail(value)) throw new InvalidEmailError(value);
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
集約
単一のルートエンティティ(集約ルート)を持つ、エンティティと値オブジェクトのクラスター。すべてのアクセスはルートを通して行われます。
class Order { // 集約ルート
private items: OrderItem[] = [];
addItem(product: ProductRef, quantity: number): void {
// ビジネスロジックはここに、サービスには書かない
if (quantity <= 0) throw new InvalidQuantityError();
this.items.push(new OrderItem(product, quantity));
}
get total(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero());
}
}
集約のルール:
- 他の集約はIDでのみ参照する
- 1トランザクションにつき1つの集約
- 集約間の結果整合性
ドメインイベント
ドメインエキスパートが関心を持つ、ドメイン内で発生した何か。
class OrderPlaced implements DomainEvent {
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
readonly total: Money,
readonly occurredAt: Date
) {}
}
ドメインサービス
エンティティや値オブジェクトに自然には属さないビジネスロジック。
class PricingService {
calculatePrice(order: Order, customer: Customer, promotions: Promotion[]): Money {
// 複数の集約にまたがる複雑な価格計算ロジック
}
}
リポジトリ
データアクセスに対する抽象化。集約ルートごとに1つのリポジトリ。
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}
ディレクトリ構成 (DDD)
src/
├── modules/
│ └── orders/ # 境界付けられたコンテキスト
│ ├── domain/
│ │ ├── entities/
│ │ │ └── order.ts # 集約ルート
│ │ ├── value-objects/
│ │ │ └── money.ts
│ │ ├── events/
│ │ │ └── order-placed.ts
│ │ ├── services/
│ │ │ └── pricing.ts
│ │ └── repositories/
│ │ └── order.repository.ts # インターフェース
│ ├── application/
│ │ ├── commands/
│ │ │ └── place-order.ts
│ │ ├── queries/
│ │ │ └── get-order.ts
│ │ └── handlers/
│ │ └── place-order.handler.ts
│ └── infrastructure/
│ ├── persistence/
│ │ └── order.repository.impl.ts # 実装
│ └── api/
│ └── orders.controller.ts
クリーンアーキテクチャの依存関係ルール
DDDでは、依存関係は厳密に内向きである必要があります:
[ フレームワークとドライバ (Web, DB, UI) ]
└──▶ [ インターフェースアダプター (コントローラー, ゲートウェイ) ]
└──▶ [ アプリケーション (ユースケース, CQRSハンドラー) ]
└──▶ [ ドメイン (エンティティ, 値オブジェクト) ]
- ドメイン層は、ORM(Prisma、TypeORM)、HTTPフレームワーク(Express、NestJS)、外部SDKへの依存を一切持たない。
- リポジトリはドメイン/アプリケーション層でインターフェースとして定義され、インフラストラクチャ層で実装される。
ドメインイベントと統合イベント
- ドメインイベント: 単一の境界付けられたコンテキスト内での状態変更を表す。
- 集約ルート内で直接発生させる(
order.addItem(...)がOrderItemAddedを発生させる)。 - トランザクションコミット前にプロセス内でディスパッチされる。
- 集約ルート内で直接発生させる(
- 統合イベント: 境界付けられたコンテキストの境界を越えて、他のサービスと通信するために公開される。
- トランザクショナルアウトボックスパターンを使用してメッセージブローカーにディスパッチされる。
- バージョン管理された後方互換性のあるスキーマを使用する必要がある。
アンチコラプションレイヤー (ACL)
外部の境界付けられたコンテキストやサードパーティベンダーAPI(例:Stripe、Salesforce)からデータを利用する場合:
- 外部ドメインモデルをドメインに直接インポートしてはならない。
- インフラストラクチャ層にACLトランスレーター/アダプターを作成し、外部DTOを独自の値オブジェクトとエンティティに変換する。
アンチパターン
- [失敗] 貧血ドメインモデル — ゲッター/セッターしか持たないエンティティ、すべてのロジックがサービスにある
- [失敗] 巨大集約 — 集約は小さく、不変条件に焦点を当てるべき
- [失敗] 集約間トランザクション — 結果整合性を使用する
- [失敗] DDDの濫用 — 複雑性が正当化される場合にのみDDDを使用する
- [失敗] ORMエンティティのドメインへの漏洩 — ドメインエンティティは
@Entity()やORMデコレータに依存してはならない
コード例
詳細なコード例については EXAMPLES.md を参照してください。
検証チェックリスト
タスク完了前のレビューフェーズで確認すべき項目。
よくある間違い
アンチパターンと明示的に避けるべき事項。TROUBLESHOOTING.md を参照してください。
統合ノート
このスキルが他のスキルとどのように連携するか。
ddd トラブルシューティングとよくある間違い
1. 神集約
- 症状: 集約ルートが20の子エンティティを含み、ロードするために数十のテーブルを結合する必要がある。
- 根本原因: ERDテーブルをトランザクション整合性の単位ではなく、集約境界として扱っている。
- 修正: 小さな集約を設計する。他の集約はオブジェクト参照ではなく、IDでのみ参照する。
2. インフラストラクチャのドメイン層への漏洩
- 症状: ドメインエンティティがPrisma、TypeORMデコレータ、Express Requestオブジェクトをインポートしている。
- 根本原因: クリーンアーキテクチャの境界を逆転させている。
- 修正: ドメイン層は、外部フレームワークへの依存が一切ない、純粋なTypeScriptでなければならない。
3. 複数の集約にまたがるトランザクション
- 症状: 同時トランザクション下での高いデータベースロック競合とデッドロック。
- 根本原因: 同じデータベーストランザクション内で複数の集約ルートを変更している。
- 修正: 経験則: トランザクションごとに厳密に1つの集約ルートのみを変更する。他の集約との結果整合性にはドメインイベントを使用する。