domain-driven-design について
DDDを採用したプロジェクトで、業務ルールやドメイン用語に沿ったコード設計を支援するスキルです。コードの配置、モデル設計、用語集の維持を案内し、詳細資料を必要に応じて参照できます。
主な機能
- 01DDDの適用場面の判断
- 02ドメインとアプリケーションの責務分担
- 03共通言語と用語集の管理
- 04値オブジェクトとエンティティの設計
- 05必要に応じた詳細資料の参照
活用シーン
- 01複雑な業務ドメインのモデル化
- 02業務ロジックの配置先の判断
- 03ドメイン用語の命名統一
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill domain-driven-designインストール後、Claude Code で/domain-driven-designを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
resources/aggregate-design.mdbounded-contexts.mddomain-events.mddomain-services.mderror-modeling.mdreferences.mdtesting-by-layer.md詳細
ドメイン駆動設計(DDD)
このスキルは、DDD を採用しているプロジェクトにのみ適用されます。異なるアプローチを採用しているプロジェクトには、これらのパターンを適用しないでください。
ヘキサゴナルアーキテクチャ(ポートとアダプター)については、hexagonal-architecture スキルを読み込んでください。DDD とヘキサゴナルアーキテクチャは相互に補完しますが、独立したものです。一方だけを採用するプロジェクトもあります。
物理的な配置を設計または変更する場合は、structure-codebase スキルを使用してください。このスキルでは、境界づけられたコンテキスト、機能/ユースケースの凝集性、パッケージの適用、採用している場合はヘキサゴナルアーキテクチャの内側/外側の境界を、それぞれ別の構造的な軸として扱います。物理的な再構成が求められていない場合は、ドメインロジックを分離したまま、リポジトリの既存のレイアウトを維持してください。
詳細な解説資料は resources/ ディレクトリにあります。必要に応じて読み込んでください。
| リソース | 読み込むタイミング |
|---|---|
aggregate-design.md | 集約の設計や分割、サイズに関する疑問、楽観的ロックについて検討するとき |
domain-services.md | ロジックがドメインサービスかユースケースか判断に迷うとき、命名規則を確認するとき |
domain-events.md | 集約間の連携、Decider パターン、イベントのディスパッチ(アウトボックス)、プロセスマネージャーについて検討するとき |
bounded-contexts.md | コンテキストの境界を定めるとき、外部システムとの連携(ACL)、コンテキストマッピングについて検討するとき |
error-modeling.md | 結果型と例外のどちらを使うか、エラー伝播について判断するとき |
testing-by-layer.md | DDD コードのテストや、不変条件に対するプロパティベーステストを書くとき |
references.md | DDD、レイヤーの責務、テストに関する、焦点を絞った根拠資料を確認するとき |
信頼できる情報源については、resources/references.md を参照してください。
DDD を使用するタイミング
DDD は、豊富なビジネスルールを持つ複雑なドメインで効果を発揮します。すべてのプロジェクトに必要なわけではありません。
DDD を使用する場合:
- ドメインに複雑なビジネスルールや不変条件がある
- ドメインの専門知識を持つ複数のステークホルダーがいる
- ビジネスロジックが主要な差別化要因である
- 用語に、具体的で重要な意味がある
DDD を使用しない場合:
- ビジネスルールのない単純な CRUD である
- 技術やインフラストラクチャが中心のプロジェクトである
- 相談できるドメインエキスパートがいない
シンプルに始めて、発展させてください: ユビキタス言語(用語集)と値オブジェクトから始めます。集約、ドメインイベント、境界づけられたコンテキストを追加するのは、ドメインが必要とするときだけです。最初のモデルは間違っているものです。それで問題ありません。目標は、すばやく学び、より深い洞察に向けてリファクタリングすることです。
核となる原則
コードはドメインの言葉で語らなければなりません。 リポジトリで宣言された用語集は、各境界づけられたコンテキストにおける命名の基準です。宣言済みの概念に名前を付けるときは、型、関数、変数、テストで、その正式な表記と大文字・小文字の使い方を使用してください。用語集にない概念は、自動的に承認または禁止されるわけではありません。まだ合意されていないということです。計画、調査、コメント、会話、既存のコードから候補となる語彙が見つかることはありますが、それらによって暗黙のうちに正式な用語として確定することはできません。
ドメインモデルは進化します。 最初のモデルが最終的なモデルになることはありません。ドメインエキスパートとの会話や、動作するソフトウェアの構築を通じて理解が深まるにつれ、モデルは変化していく必要があります。型の名前が変わり、集約が分割または統合され、新しい概念が生まれます。これは想定された望ましいことです。決して変化しないモデルは、完璧である(可能性は低い)か、停滞している(チームが学ぶのをやめた)かのどちらかです。TDD と振る舞いテストによって、この進化を安全に進められます。概念の名前を変更し、用語集を更新すれば、何を変更する必要があるかテストが教えてくれます。
このコードはどこに置くべきですか?
これは DDD で最もよくある判断です。迷った場合は、次のフレームワークを使用してください。
以下の役割は、配置に関する論理的な指針です。物理的なフォルダーやパッケージを選ぶには、structure-codebase を使用してください。
| 質問 | はいの場合 → | いいえの場合 ↓ |
|---|---|---|
| ビジネスルールを適用する、またはビジネス上の値を計算しますか? | ドメインポリシー(エンティティ関数、値オブジェクト、またはドメインサービス) | ↓ |
| ルールを所有せずに、ドメイン操作を調整しますか? | アプリケーションポリシー/ユースケース | ↓ |
| 表示用のデータを整形、変換、または準備しますか? | プレゼンテーションコード。ヘキサゴナルアーキテクチャでは、多くの場合、駆動側のエッジに置かれます | ↓ |
| 外部システム(DB、API、ファイルシステム)と通信しますか? | インフラストラクチャ/統合コード。ヘキサゴナルアーキテクチャでは、駆動アダプターが内側で所有されるポートを実装します | ↓ |
| フレームワーク固有の接着コード(ルートハンドラー、ミドルウェア)ですか? | フレームワークのエントリーポイント。ヘキサゴナルアーキテクチャでは、駆動アダプターです | — |
純粋性のテストは必要ですが、それだけでは十分ではありません。 表示用に日付を整形する純粋関数は、純粋であるというだけでドメインポリシーに属するわけではありません。常に問うべきなのは、「これはビジネスルールか?」です。
// ❌ Pure but NOT domain — formats for human display
export const formatEventDate = (date: string | null) =>
date ? format(parseISO(date), "MMMM d, yyyy") : undefined;
// → Belongs in presentation code; a hexagonal app may place it at the driving edge
// ✅ Pure AND domain — business rule that affects behavior
// The application supplies current time as data; domain code does not read a clock.
export const isPastEvent = (eventDate: Date | null, now: Date) =>
eventDate ? eventDate.getTime() < now.getTime() : false;
// → Belongs in domain policy for events
// ✅ Pure AND domain — business calculation
export const calculateCommittedTotal = (items: readonly GiftItem[]) =>
items.filter(i => i.status !== "idea").reduce((sum, i) => sum + i.pricePence, 0);
// → Belongs in domain policy for budgets
配置が重要な理由: ドメインポリシーのコードには通常、厳格なカバレッジ要件があり、インフラストラクチャのインポートは一切ありません。コードを誤った役割に置くと、不必要なテスト要件やアーキテクチャ違反が生じます。
structure-codebase を適用したら、そのパッケージ/インポートルールを使用して、ドメインと、存在する場合はヘキサゴナル境界を機械的に適用してください。
アーキテクチャでポートを使用する場合、その所有者は抽象化を必要とする最も内側の利用者に従います。そのため、ユースケースが使用するリポジトリポートやゲートウェイポートは、通常、アプリケーションが所有します。ドメインモデル自体がその抽象化を実際に利用する場合に限り、ポートはドメインに属します。「内側が所有する」とは、「ドメインが所有する」という意味ではありません。
ドメインポリシーには、フレームワーク、トランスポートや永続化の型、時計、UUID ジェネレーター、ベンダー SDK、UI の関心事を持ち込まないでください。外部入力はエッジで解析し、現在時刻、識別子、その他の外部事実は型付きの値としてドメイン関数に渡してください。アプリケーションのユースケースは、それらの関数と必要なポートを調整します。
ユビキタス言語と用語集
DDD プロジェクトでは、合意されたドメイン用語を定義する、リポジトリで宣言された用語集を維持する必要があります。用語集は、その境界づけられたコンテキストにおける命名の基準です。モデルの進化に合わせて用語集も進化します。チームがより適切な名前を見つけたり、概念を分割したりした場合は、コードに反映する前に変更を決定し、記録してください。
用語集ファイル
複数の境界づけられたコンテキストがあるプロジェクトでは、コンテキストごとに整理してください。同じ用語でも、コンテキストによって定義が異なることがあります。これは正しい状態であり、バグではありません。
## Gifting Context
| Term | Definition | Examples |
|------|-----------|----------|
| Occasion | A gift-giving event (birthday, holiday) | "Mum's Birthday", "Christmas 2026" |
| Gift Idea | A potential gift for an occasion | "Cookbook", "Scarf" |
| Contribution | Money pledged toward a gift | "£25 from Dad" |
## Notifications Context
| Term | Definition | Examples |
|------|-----------|----------|
| Occasion | An upcoming event that may trigger reminders | (same events, different concern) |
| Recipient | The person being gifted — target of reminder scheduling | "Mum" |
適用ルール
- 宣言された概念に名前を付ける場合は常に、正規の綴りと大文字・小文字の表記を使用してください。依頼、チケット、ユーザーが却下された別名を使っている場合も同様です。依頼の表現は翻訳対象であり、命名の基準ではありません。用語集で
Bookingが宣言され、Reservation が却下されているのに、依頼で「reservation」と書かれている場合、コードではBookingを使用し、Reservationは型名、フィールド名、関数名として一切使用せず、どの語を翻訳したかを返信で伝えてください。 - 用語集にない語は、自動的に承認または禁止されたものとして扱わず、合意が必要な候補として扱ってください。
- 却下または非推奨となった別名には、所有する境界づけられたコンテキストと正規の置き換え語を示してください。
- チェックで宣言された範囲内で、用語集のチェックを
typeおよびinterfaceの名前、関数名、テストの説明に適用してください。 - 言語リンターのコマンドが成功しても、それは対象となるラチェットの根拠に限られ、すべての文章や識別子がユビキタス言語を正しく使用している証明とはみなさないでください。
// ✅ Uses domain language
type GiftIdea = {
readonly id: GiftIdeaId;
readonly description: string;
readonly occasion: OccasionId;
readonly estimatedCost: Money;
readonly status: 'proposed' | 'selected' | 'purchased';
};
// ❌ Technical jargon
type Item = { readonly id: string; readonly text: string; readonly parentId: string; };
構成要素
値オブジェクト
不変で、同一性を持たず、参照ではなく属性によって比較されます。属性によって定義されるドメインの概念を表します。同じ最小通貨単位と通貨を持つ 2 つの Money 値は等しくなります。値オブジェクトは同一性を持ちません。
type Currency = 'GBP' | 'USD' | 'EUR';
type Money = { readonly minorUnits: number; readonly currency: Currency };
const createMoney = (minorUnits: number, currency: Currency): Money => {
if (!Number.isSafeInteger(minorUnits)) throw new Error('Money minor units must be a safe integer');
if (minorUnits < 0) throw new Error('Money cannot be negative');
return { minorUnits, currency };
};
// Factory throws = invariant violation (a bug in calling code).
// Schemas catch invalid user input at trust boundaries BEFORE
// the factory is called. If the factory throws, something
// bypassed the schema.
金額は整数の最小通貨単位で保存・計算し、主要通貨単位の 2 進浮動小数点数は決して使いません。10 進数のテキストを受け取る境界では、通貨の指数と、過剰な精度に対する明示的なポリシー(たとえば、拒否するか、銀行丸めを行うか)を使用します。Math.round(rawNumber * 100) で変換してはいけません。
信頼境界を越える値オブジェクト(API 入力、フォームデータ)には、Zod スキーマを使用します。ドメイン内部の値オブジェクトには、プレーンな型とファクトリー関数で十分です。スキーマファーストのパターンについては、typescript-strict スキルを参照してください。
Zod からブランド型への橋渡し — 信頼境界で、未加工の入力を解析してブランド付きドメイン型に変換します。
// Schema at trust boundary — parses raw strings into branded types
const PledgeInputSchema = z.object({
occasionId: z.string().min(1).transform(createOccasionId),
contributorId: z.string().min(1).transform(createContributorId),
amount: z.object({ minorUnits: z.number().int().safe().positive(), currency: CurrencySchema }),
});
// Reconstitution from persistence — same pattern, used at the integration boundary
// (a driven adapter in hexagonal architecture loads the gift ideas alongside the row)
const toOccasion = (row: OccasionRow, giftIdeas: ReadonlyArray<GiftIdea>): Occasion => ({
id: createOccasionId(row.id),
name: row.name,
giftIdeas,
budget: createMoney(row.budgetMinorUnits, parseCurrency(row.budgetCurrency)),
totalPledged: createMoney(row.pledgedMinorUnits, parseCurrency(row.budgetCurrency)),
isFundingClosed: row.isFundingClosed,
});
再構成(DB 行からドメインオブジェクトを再構築すること)には、作成時と同じファクトリー関数を使用します。ファクトリーは検証を行うため、無効な永続化データはドメインを気づかないうちに破損させるのではなく、読み込み時に検出されます。
ブランド型
ブランド型のパターン自体については、typescript-strict スキルで説明しています。一般的なルールを確認するには、そのスキルを読み込んでください。DDD における具体的な適用方法は次のとおりです。
- 各エンティティに固有のブランド付き ID(
OccasionId、GiftIdeaId)を与えます。コンパイラーがエンティティ間の ID の取り違えを拒否するため、OccasionIdが期待される箇所にGiftIdeaIdを渡すと、実行時のバグではなくコンパイルエラーになります。 - 各ブランド型には、検証を行うファクトリーを 1 つだけ設け、そこを通じて一度だけブランド化します。未加工の文字列は検証後にのみブランド付きの値になります。ファクトリー内の唯一の
asが、そのブランドに対して許可される唯一のアサーションです。検証してからブランド化します。予算は、ブランド型ごとにasをちょうど 1 つ、それ以外では 0 個です。ブランド付きの値から派生した値(別の日付から計算した日付、行から読み戻した ID など)は、別のasを記述するのではなく、ファクトリーを再度呼び出してブランド化します。 - エンティティ ID と、単一値の値オブジェクト(
EmailAddress)にはブランド型を使用します。
type OccasionId = string & { readonly __brand: 'OccasionId' };
type GiftIdeaId = string & { readonly __brand: 'GiftIdeaId' };
const createOccasionId = (raw: string): OccasionId => {
if (!raw.trim()) throw new Error('OccasionId cannot be empty');
return raw as OccasionId; // justified: factory validates, then brands
};
エンティティ
同一性とライフサイクルを持ちます。構築後または状態遷移後は、常に有効です。
type Occasion = {
readonly id: OccasionId;
readonly name: string;
readonly giftIdeas: ReadonlyArray<GiftIdea>;
readonly budget: Money;
readonly totalPledged: Money;
readonly isFundingClosed: boolean;
};
// Immutable update — returns new valid state
const renameOccasion = (occasion: Occasion, newName: string): Occasion => ({
...occasion,
name: newName,
});
常に有効であるという原則: エンティティは、常に不変条件を満たしていなければなりません。構築時(ファクトリー関数またはスキーマ解析)と、すべての状態遷移時に検証します。一時的であっても、無効な状態のエンティティが存在することを許してはいけません。
不正な状態を表現不可能にする
一般的な型安全性のパターン(避けられない相互運用の境界に限った any の封じ込め、判別可能なユニオン、スキーマファーストの検証)については、typescript-strict スキルで説明しています。DDD における具体的な適用方法としては、エンティティのライフサイクルを、各バリアントがその状態で有効なデータのみを持つ判別可能なユニオンとしてモデル化します。矛盾した組み合わせ({ isVerified: true, verifiedAt: undefined } など)を許してしまう、真偽値フラグとオプションフィールドの組み合わせは決して使いません。
type Order =
| { readonly status: 'draft'; readonly items: ReadonlyArray<OrderItem> }
| { readonly status: 'placed'; readonly items: ReadonlyArray<OrderItem>; readonly placedAt: Date }
| { readonly status: 'shipped'; readonly items: ReadonlyArray<OrderItem>; readonly placedAt: Date; readonly shippedAt: Date; readonly trackingNumber: string };
ライフサイクルのすべてのバリアントを必ず網羅的に処理します。 新しいバリアントを追加した際、未処理の状態があればコンパイラーが検出できるように、never 型を使用します。
const describeOrder = (order: Order): string => {
switch (order.status) {
case 'draft': return `Draft with ${order.items.length} items`;
case 'placed': return `Placed at ${order.placedAt.toISOString()}`;
case 'shipped': return `Shipped: ${order.trackingNumber}`;
default: { const _exhaustive: never = order; return _exhaustive; }
}
};
集約
単一のルートを持つ、エンティティと値オブジェクトの集まりです。すべての変更はルートを通じて行います。
- 1 つのトランザクションにつき集約ルートは 1 つ
- 他の集約は ID で参照します — 埋め込んではいけません
- すべての不変条件はルートで強制します
- 集約は小さく保ちます — 一貫性の維持に必要なものだけを含めます
集約設計の詳細なガイダンスについては、resources/aggregate-design.md を参照してください。
仕様(述語関数)
フィルタリング、適格性、検証のための複雑なビジネスルールは、ドメイン層の述語関数として表現します。Evans はこれを「仕様」と呼んでいます。
// Specification: "can this eligible contributor pledge to this occasion?"
const canPledge = (occasion: Occasion, eligibility: ContributorEligibility, amount: Money): boolean => {
const values = [amount.minorUnits, occasion.totalPledged.minorUnits, occasion.budget.minorUnits];
if (!values.every(Number.isSafeInteger)) return false;
if (occasion.totalPledged.minorUnits > occasion.budget.minorUnits) return false;
return eligibility.mayPledge &&
!occasion.isFundingClosed &&
amount.minorUnits > 0 &&
amount.currency === occasion.totalPledged.currency &&
occasion.totalPledged.currency === occasion.budget.currency &&
amount.minorUnits <= occasion.budget.minorUnits - occasion.totalPledged.minorUnits;
};
// Compose specifications for complex eligibility
const isGiftReady = (occasion: Occasion): boolean =>
occasion.totalPledged.minorUnits >= occasion.budget.minorUnits &&
occasion.giftIdeas.some(idea => idea.status === 'selected');
仕様は純粋な述語関数であり、boolean を返し、副作用はありません。ドメインサービス、ユースケース、クエリフィルターで使用します。is、can、has のいずれかを接頭辞として名前を付けます。
ドメインイベント
ドメインイベントは、ドメインで起きた意味のある出来事(「OrderPlaced」「ContributionPledged」など)を表します。集約や境界づけられたコンテキストをまたぐ副作用を調整します。
ドメインイベントが複雑さに見合うのは、次の場合です。
- 副作用が集約の境界を越える
- ほかの境界づけられたコンテキストが変更に反応する必要がある
- 監査証跡やイベント駆動のワークフローが必要である
次の場合はドメインイベントを追加しないでください。
- すべてのロジックが単一の集約内に収まる
- 副作用が同じトランザクション内で完結する
- ドメイン関数が返す明示的な戻り値で十分である
ほとんどのプロジェクトでは、まずドメインイベントを使わずに始め、ドメインで調整が必要になったら追加してください。Decider パターンと詳しいガイダンスについては、resources/domain-events.md を参照してください。イベントが信頼できる情報源となり、追記専用ログに永続化して状態の再構築に再生するようになった場合、それはイベントソーシングです。event-sourcing スキルを読み込んでください(この Decider を直接ベースにしています)。ドメインイベントがすでにある場合、オブザーバビリティは別の購読者として自然に実現できます。既存のパブリッシャーポートにオブザーバビリティアダプターを追加し、別の通知経路を設けないでください(hexagonal-architecture スキルの4層オブザーバビリティモデルと observability スキルを参照)。
ドメインサービス
ビジネスロジックが単一のエンティティに属さない場合は、ドメインサービスに置きます。ドメイン層にある、ドメイン値や読み取り専用のポリシー情報を組み合わせるステートレスな関数です。ドメインサービスは、複数の集約に対する書き込みをアトミックにはしません。不変条件を保つために同期的な変更が必要な場合は集約を見直し、またはアプリケーション層で1つの集約への書き込みと永続的なイベントを調整してください。
// ❌ WRONG — cramming an external eligibility policy into one entity
const addContribution = (occasion: Occasion, contribution: Contribution): Occasion => {
// This cannot know whether the contributor is currently eligible.
};
// ✅ CORRECT — pure domain service consumes a read-only policy fact
const pledgeContribution = (
occasion: Occasion,
eligibility: ContributorEligibility,
pledge: { readonly id: PledgeId; readonly amount: Money },
): PledgeDecision => {
if (!eligibility.mayPledge) {
return { success: false, reason: 'contributor-ineligible' };
}
if (occasion.isFundingClosed) {
return { success: false, reason: 'funding-closed' };
}
if (
pledge.amount.currency !== occasion.totalPledged.currency ||
occasion.totalPledged.currency !== occasion.budget.currency
) {
return { success: false, reason: 'currency-mismatch' };
}
const values = [
occasion.totalPledged.minorUnits,
occasion.budget.minorUnits,
pledge.amount.minorUnits,
];
if (!values.every(Number.isSafeInteger)) throw new Error('Invalid Money invariant');
if (pledge.amount.minorUnits <= 0) {
return { success: false, reason: 'non-positive-amount' };
}
if (occasion.totalPledged.minorUnits > occasion.budget.minorUnits) {
throw new Error('Invalid Occasion funding invariant');
}
if (pledge.amount.minorUnits > occasion.budget.minorUnits - occasion.totalPledged.minorUnits) {
return { success: false, reason: 'exceeds-budget' };
}
const totalPledged = occasion.totalPledged.minorUnits + pledge.amount.minorUnits;
if (!Number.isSafeInteger(totalPledged)) throw new Error('Money addition overflowed');
return {
success: true,
occasion: { ...occasion, totalPledged: createMoney(totalPledged, pledge.amount.currency) },
events: [{
type: 'PledgeRecorded',
id: pledge.id,
occasionId: occasion.id,
contributorId: eligibility.contributorId,
amount: pledge.amount,
}],
};
};
ドメインサービスとユースケース(アプリケーションサービス)の違い:
| ドメインサービス | ユースケース | |
|---|---|---|
| ビジネスロジックを含むか | はい | いいえ — オーケストレーションのみ |
| 論理的な役割 | ドメインポリシー | アプリケーションポリシー — ドメイン操作と連携を調整 |
| 依存先 | デフォルトではドメイン型のみ | ドメインサービス、および通常はアプリケーションが所有するリポジトリ/連携ポート |
| 例 | pledgeContribution(occasion, eligibility, pledge) | handlePledge(persistence, eligibilityGateway, dto) — 読み込み、ドメインサービスの呼び出し、1つの集約とそのアウトボックスイベントの保存 |
デフォルトでは、ドメインのビジネス上の判断を純粋なものに保ってください。ドメインが所有する駆動ポートを使うのは、アプリケーションのオーケストレーションではなくモデル自体がやり取りを所有する場合に限る、まれで明示的な例外です。この場合、利用するドメインサービスは副作用を持ちますが、プロバイダーには依存しません。純粋な判断ロジックを分離し、フェイクを使ってポートとのやり取りをテストしてください。アプリケーションのオーケストレーションでのみ必要なリポジトリやゲートウェイに、この例外を適用しないでください。
物理的な配置は、選択したアーキテクチャによって異なります。技術的な役割より先に、境界づけられたコンテキストまたはビジネスケイパビリティを基準に構成してください。ヘキサゴナル構造が明確なバックエンドでは、どちらの役割も内側に置きます。依存関係の境界に見合う価値がある場合は、通常、ドメインとアプリケーションを別々のパッケージに分けます。または、1つのケイパビリティパッケージ内で、まとまりのある機能/ユースケースモジュールにまとめます。ヘキサゴナルアーキテクチャを使わない DDD では、コンテキストを軸とした別の構成を採用する場合もあります。domain/ に内側のコードがすべて含まれると思い込まず、structure-codebase に従ってください。
詳しいガイダンスについては、resources/domain-services.md を参照してください。
エラーのモデリング
想定されるビジネス上の結果には、識別可能なユニオン型の結果を使ってください。例外はプログラマーのミスやインフラストラクチャの障害に限ります。
type PledgeDecision =
| { readonly success: true; readonly occasion: Occasion; readonly events: readonly PledgeRecorded[] }
| { readonly success: false; readonly reason: 'contributor-ineligible' | 'non-positive-amount' | 'currency-mismatch' | 'exceeds-budget' | 'funding-closed' };
type PledgeResult =
| PledgeDecision
| { readonly success: false; readonly reason: 'not-found' | 'concurrent-change' }; // application outcomes
**判定基準:**ユーザーの操作によって、この結果が正当に起こり得ますか?はいの場合 → 結果型。いいえの場合(バグを意味する場合)→ 例外。
詳しいエラーモデリングのパターンと、エラーが各層をどのように伝播するかについては、resources/error-modeling.md を参照してください。
リポジトリパターン
リポジトリは、集約にコレクションのようなアクセスを提供します。リポジトリを利用する、最も内側にあるポリシーと同じ場所にインターフェースを配置し、具象実装はインフラストラクチャまたは統合コードとともに配置します。明示的なヘキサゴナルシステムでは、ユースケースが集約を読み込み、保存するため、リポジトリポートは通常、アプリケーションが所有します。アプリケーションポリシーの近くに配置し、駆動アダプターに実装させます。ドメインモデル自体がその抽象化を本当に所有し、利用する場合に限り、ドメインに配置します。ポートとアダプターを採用していない DDD では、暗黙的にアダプターレイヤーが生じるわけではありません。structure-codebase で選択したアーキテクチャに従ってください。リポジトリは振る舞いの契約を定義するため、type ではなく interface を使用し、メソッドにはドメインの言葉を使います。
// Application-owned repository contract — an inside-owned port when hexagonal architecture is used
interface OccasionRepository {
readonly findById: (id: OccasionId) => Promise<Occasion | undefined>;
readonly save: (occasion: Occasion) => Promise<void>;
}
// Concrete implementation belongs with infrastructure/integration;
// in hexagonal architecture it is a driven adapter.
リポジトリが扱うのは、書き込みと単一集約の読み取りです。 集約をまたぐ JOIN が必要な読み取り(ダッシュボードのビューや、複数のエンティティのデータを組み合わせる詳細ページなど)には、リポジトリは適していません。リポジトリは、読み取りでまたぐ必要のある集約境界を守るためです。代わりに、自由に JOIN するクエリ関数を使ってください。これは CQRS-lite パターンです。書き込みにはリポジトリ(整合性)を使い、読み取りにはクエリ関数(柔軟性)を使います。詳しくは、hexagonal-architecture スキルの CQRS-lite セクションと、その resources/cqrs-lite.md を参照してください。
読み取りが単一の集約にきれいに対応する単純なドメインでは、リポジトリで読み取っても問題ありません。早まって分離しないでください。
DDD と TDD の統合
実装ファイルではなく、ドメイン概念ごとにテストする
プロジェクトで選択した物理的な構成に従い、対象の振る舞いの近くに焦点を絞ったテストを配置してください。DDD だからといって、ルートに tests/ ディレクトリが必要になるわけではありません。
describe ブロックと it ブロックのタイトルには、テスト対象の関数名ではなく、用語集に沿ったビジネス上の振る舞いを記述してください。
// ❌ Titled after helpers — proves nothing a domain expert would recognise
describe('addDays', () => { it('rolls over a month boundary', ...) });
describe('createMoney', () => { it('rejects a negative amount', ...) });
// ✅ Titled after the rule
describe('Pledging to an Occasion', () => { it('is refused once funding is closed', ...) });
describe('Committed total for an Occasion', () => { it('excludes gift ideas that are only proposed', ...) });
日付や算術のヘルパーには、それ自体の describe を設けません。そのヘルパーを使うルールを通じて検証します。実装ファイルごとにテストファイルを1つ作ると、ヘルパーの名前を使ったテストが生まれやすくなります。代わりに、テストする概念ごとにテストをまとめてください。
<selected-context-or-feature>/
occasions/
create-occasion.test.ts # Behavior: creating occasions
add-gift-idea.test.ts # Behavior: managing gift ideas
occasion-budget.test.ts # Behavior: budget constraints
主たる振る舞いの境界を選ぶ
選択したアーキテクチャに実際にある、最も広く安定した公開振る舞いを通じてテストしてください。
- ドメインのみのモデルやライブラリでは、集約の操作、ドメインサービス、または Decider を直接呼び出します。これらのテストが主たるテストであり、代替手段ではありません。
- アプリケーションポリシーがある場合は、連携する契約に対する単純なフェイクやスタブを使って、ユースケースを呼び出します。ポートを新たに作らずに、ドメインの振る舞いとオーケストレーションをまとめてテストできます。
- 明示的なヘキサゴナルシステムでは、駆動ポートをテストし、駆動されるアクターを外部のテスト用インタラクターに置き換えます。このテスト戦略については、
hexagonal-architectureを読み込んでください。 - 統合テストとデリバリーテストは、DDD がレイヤーのピラミッドを要求するからではなく、実際のインフラストラクチャとユーザーへのリスクに応じて追加します。
複雑な純粋ビジネスルールに対する焦点を絞ったテストは、アプリケーションテストがある場合に、それを補完します。プロバイダーに依存しないドメインパッケージでは、それだけで主要なテストスイート全体を構成する場合もあります。
アーキテクチャに依存しない判断については、resources/testing-by-layer.md を参照してください。ヘキサゴナルアーキテクチャも採用する DDD システムについては、ヘキサゴナルスキルの resources/worked-example.md で、1つの機能をポート、アダプター、テストにまたがって追跡しています。
テストファクトリーではドメインの言葉を使う
const getTestOccasion = (overrides?: Partial<Occasion>): Occasion =>
OccasionSchema.parse({
id: createOccasionId('occasion-1'),
name: "Mum's Birthday",
giftIdeas: [],
budget: createMoney(10_000, 'GBP'),
totalPledged: createMoney(0, 'GBP'),
isFundingClosed: false,
...overrides,
});
境界づけられたコンテキスト
境界づけられたコンテキストとは、特定のドメインモデルと用語集が適用される言語上の境界です。同じ単語(たとえば「User」)でも、コンテキストによって意味が異なることがあります。それで問題ありません。
- 各コンテキストは独自のモデルと用語集を持ちます — 課金における
Userは、配送におけるUserとは異なります - イベントまたは明示的な契約を介してコンテキスト間でやり取りします — 可変状態を共有してはいけません
- 腐敗防止層(ACL) — 外部システムや、自分たちのモデルに合わない別のコンテキストと連携する場合、その型が入り込まないように、境界で変換します
- 共有カーネルは最小限にします — 真に普遍的な値オブジェクト(Money、Email)のみを含めます。共有カーネルが大きくなる場合、境界が不明確です
- 各コンテキストに独自の用語集セクションを設けます
コンテキストマッピングのパターン、モノレポの構成、ACL の例については、resources/bounded-contexts.md を参照してください。
アンチパターン
貧血ドメインモデル
エンティティが振る舞いを持たないデータの入れ物になり、ロジックがすべて「サービス」に置かれています。対策:振る舞いは、それが操作する型の近くに純粋関数として配置します。
汎用的な技術用語
ドメイン用語の代わりに Item、Entity、Record、Data、Info を使用することです。常に用語集を使用してください。
ドメイン内のプレゼンテーションロジック
表示形式の整形は、ドメインポリシーに含めるものではありません。判断基準は、「人にとって見やすく整える」ならプレゼンテーション、「ビジネスルールを適用する」ならドメインです。純粋であるだけでは十分ではありません。純粋な整形関数も、依然としてプレゼンテーションです。
ドメインロジックの漏出
ルートハンドラー、データベースクエリ、アダプターにビジネスロジックを置くことです。プロジェクトで選択した物理構造に従い、ドメインポリシーモジュール内に保持してください。
関係性主導の集約
不変条件を見つけるのではなく、エンティティ階層(「Order が OrderLines を含み、OrderLine が Product を含む」)をたどって集約を設計することです。見分けるポイントは、集約のメソッドが子要素の追加、削除、関連付けしか行わず、ビジネスルールを適用していないことです。集約の役割が親子関係の管理だけなら、その関係は集約階層ではなく、データベース制約または単純なデータモデルとして扱うべきです。
対策:「状態が変化したとき、何が真であり続けなければならないか?」と考えます。答えが構造上のもの(多対一、一対一)に限られるなら、集約は必要ありません。必要なのは外部キーです。コマンドの実行時に振る舞い上の不変条件を適用するとき、集約の複雑さが意味を持ちます。
過剰な設計
すべてのプロジェクトに集約、ドメインイベント、境界づけられたコンテキストが必要なわけではありません。次のことから始めます。
- ユビキタス言語(用語集)
- 値オブジェクトとエンティティ
- ドメインが求める場合にのみ複雑さを加える
モデルの進化への抵抗
初期モデルを神聖視し、理解が深まっても型の名前変更、集約の分割、境界づけられたコンテキストの再構成を拒むことです。モデルは継続的に進化させるべきです。リファクタリングによって「Occasion」は本当は「GiftEvent」と「SavingsGoal」にすべきだとわかったなら、そうしてください。用語集と型を変更し、テストを指針に移行します。Evans はこれを「ブレークスルー」と呼んでいます。チームがドメインについて新たに学んだことで、モデルが根本的に改善される瞬間です。
チェックリスト
- 用語集ファイルが存在し、最新の状態である
- 宣言された概念では、用語集に定められた正式な表記と大文字・小文字の使い方を使用している
- 不足している用語は、合意なしに採用せず、候補として扱っている
- 採用しない別名には、所管する境界づけられたコンテキストと、置き換え先となる正式名称を明記している
- 言語リンターが成功した場合は、実際に対象とした範囲を報告している
- 値オブジェクトは不変で、同一性を持たない
- エンティティは常に有効である(構築時および状態遷移時に不変条件を適用している)
- エンティティにはブランド付き ID があり、プリミティブな値オブジェクトにはブランド型を使用している
- 集約ルートがすべての不変条件を適用している
- 集約境界はエンティティ間の関係ではなく、不変条件に基づいて正当化されている
- 集約に、クエリの利便性のみを目的とする読み取り専用プロパティが含まれていない
- 他の集約は埋め込まず、ID で参照している
- 集約間のロジックはドメインサービスに置き、1 つのエンティティに詰め込んでいない
- ユースケースとオーケストレーションはアプリケーションポリシーに置き、ドメインポリシーには置いていない
- リポジトリとゲートウェイのポートは、最も内側の利用側(通常はアプリケーションポリシー)に置き、ドメインが所有するのは実際に利用するポートのみとしている
- 具体的な駆動アダプターは、内側が所有するポートを実装し、インフラストラクチャ/統合層に置いている
- 識別付き共用体では、すべてのケースを網羅する switch 処理を行っている
- 想定されるビジネス上の結果には、例外ではなく結果型を使用している
- ドメインロジックにインフラストラクチャへの依存がない
-
structure-codebaseが適用されている場合、そのドメイン/コンテキストと、確認可能な内側/外側のルールが存在し、すべてに合格している - プレゼンテーションロジックは、純粋であってもドメインポリシーに含まれていない
- テストは実装ファイルではなく、ドメインの概念ごとに整理されている
- 各論理的な役割には、適切なレベルの振る舞いテストがある