DDDはドメイン駆動設計(Domain-Driven Design)の略で、業務のルールをそのままコードの構造に写す設計手法です。ただ、用語を覚えても既存のコードは1行も変わりません。この記事では、値オブジェクト・エンティティ・集約ルート・リポジトリを実際にどう書き、手続き型で育った既存コードをどの順番で置き換えるかを、TypeScriptの動くコードとnode:testでの検証まで含めて示します。Nodeの型消去で使えない構文、ドメインモデル貧血症に戻る境目、DDDを実装しない方が安い案件条件も扱います。用語そのものの定義はドメイン駆動設計(DDD)とは?意味・基本用語・実装と採用判断に整理してあるため、本記事は実装作業に絞りました。
まとめ:DDD実装で最初に置き換える対象と、着手順を誤ったときの手戻り
結論から言うと、着手は値オブジェクトからです。金額・数量・識別子など、業務上の意味を持つのにnumberやstringのまま扱われている値をクラスへ引き上げる作業が最も安く、影響範囲も局所で済みます。ここを飛ばして集約から手を付けると、値の検証がサービス層に散ったまま二重チェックのコードだけが増えます。
次にエンティティと集約ルート、その次にリポジトリという順です。トランザクション境界を1集約に絞る判断がこの段階で要り、ここで初めて既存のテーブル設計と衝突します。境界づけられたコンテキストの線引きは最後で構いません。先に線を引こうとすると、業務の実態ではなく現行テーブルの区切りをそのままコンテキスト境界にしてしまい、DDDの形をした旧設計ができ上がります。
そしてDDDは全案件に入れる手法ではありません。Microsoft Learnも適用対象を「複雑なサブシステム、または絶えず変化するビジネスルールを持つ」領域に限定しています。CRUD中心の管理画面に集約とリポジトリを敷けば、ファイル数だけが増えて見返りがない。この線引きは後半で条件付きで言い切ります。
DDDの実装が用語解説で止まる原因と、コードへ落とす4つの構成要素
DDDの学習でつまずく箇所は、ユビキタス言語や境界づけられたコンテキストといった戦略側の語彙と、実際に書くクラスとの距離にあります。Eric EvansによるDDD Referenceはパターンの定義集であって、特定言語の実装ガイドではありません。橋渡しになるのは、業務担当者が使う名詞と動詞をそのままクラス名・メソッド名へ写す作業です。受注業務で「与信を通す」と呼ばれている操作がupdateStatus(3)になっていれば写せていない状態で、ステータスの直値がサービス層に散り、業務ルールの説明が仕様書側にしか無い兆候を伴います。この場合はクラス構造を変える前に、担当者へのヒアリングで語彙を確定させてください。
値オブジェクト・エンティティ・集約・リポジトリの実装責務と依存の向き
実装に落ちる構成要素は4つに絞れます。値オブジェクトは「等価性を値で判定し、生成時点で不正値を弾く」担当。エンティティは「同一性をIDで判定し、状態遷移の正当性を守る」担当。集約は「一括で読み書きする単位を決め、内部の整合を保証する」担当。リポジトリは「集約の永続化を隠す」担当です。
| 構成要素 | 同一性の判定 | 主な責務 | 依存してよい先 |
|---|---|---|---|
| 値オブジェクト | 保持する値 | 不正値の排除・単位の表現 | 他の値オブジェクトのみ |
| エンティティ | ID | 状態遷移の正当性 | 値オブジェクト |
| 集約ルート | ID | 境界内の整合・外部からの唯一の入口 | 配下のエンティティと値オブジェクト |
| リポジトリ | — | 集約単位の取得と保存 | インターフェースはドメイン層、実装はインフラ層 |
依存の向きは一方向です。値オブジェクトは他の何にも依存せず、エンティティは値オブジェクトに依存し、リポジトリの実装だけがDBドライバやORMを知っています。崩れの検出は簡単で、ドメイン層のファイルのimport文にORMのパッケージ名が出てきたら、その時点で層は破れています。
値オブジェクトの実装手順とTypeScriptで不変条件を守る書き方
値オブジェクトは、生成できた時点で不正な状態が存在しないことを保証する型です。「バリデーションを通す」のではなく「不正な値ではインスタンスを作れない」形にします。
金額を表す値オブジェクトの実装例と、生成時に弾く不正値の決め方
金額をnumberで持っている既存コードを置き換える例です。コンストラクタをprivateにして、静的ファクトリ経由でしか作れないようにします。パラメータプロパティを使っていない理由は後半の型消去の章で説明します。
// src/domain/money.ts
export class Money {
readonly amount: number;
private constructor(amount: number) {
this.amount = amount;
}
static of(amount: number): Money {
if (!Number.isInteger(amount)) {
throw new RangeError("金額は円単位の整数で指定してください");
}
if (amount < 0) {
throw new RangeError("金額に負数は指定できません");
}
return new Money(amount);
}
add(other: Money): Money {
return Money.of(this.amount + other.amount);
}
multiply(times: number): Money {
return Money.of(this.amount * times);
}
equals(other: Money): boolean {
return this.amount === other.amount;
}
}
弾く不正値の決め方には基準があります。「業務上ありえない値」だけを弾き、「業務上はありうるが今回の運用では使わない値」は弾かないことです。円建てで小数が発生しないなら非整数は弾いてよい。一方、上限金額は業務ルールとして変わるため、値オブジェクトではなく集約側の不変条件に置くほうが変更に耐えます。ここを取り違えると、与信枠の変更のたびに値オブジェクトを直すはめになります。
構造的型付けの落とし穴と、ブランド型で別概念の取り違えを防ぐ実装
TypeScriptは構造的型付けを採るため、フィールド構成が同じ2つの型は互換とみなされます。OrderIdとCustomerIdをそれぞれ{ value: string }で定義すると、取り違えてもコンパイルが通る。IDの取り違えは実行時まで発覚せず、本番データを壊します。回避策は、型に識別用のプロパティを持たせるブランド型です。実行時コストはゼロで、型検査だけが効きます。
// src/domain/ids.ts
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
export type OrderId = Brand<string, "OrderId">;
export type CustomerId = Brand<string, "CustomerId">;
export function orderId(value: string): OrderId {
if (!/^ORD-\d{4,}$/.test(value)) {
throw new RangeError("受注IDの書式が不正です: " + value);
}
return value as OrderId;
}
Java・Kotlinの現場では同じ役割をjMoleculesのIdentifierやIdentifiableで表現できます。2026年9月時点のMaven Central公開版は1.9.0系で、@ValueObject・@EntityといったアノテーションとAggregateRoot・Associationの型を提供しています。言語が違っても、識別子を素の文字列で持ち回らないという判断は共通です。
エンティティと集約ルートの実装、トランザクション境界を1集約に絞る判断
集約は、Martin FowlerのDDD_Aggregate(2013年4月23日)で「単一のユニットとして扱えるドメインオブジェクトのクラスタ」と定義され、外部からの参照は集約ルートのみに向かうこと、トランザクションは集約境界を越えないことの2点が明示されています。これがそのまま実装上の制約になります。
受注集約の実装例と、明細追加をルート経由に限定するメソッド設計
受注(Order)と明細(OrderLine)を1つの集約にする例です。明細の配列を外部に公開せず、追加はルートのメソッド経由に限定します。プライベートフィールド(#lines)なら実行時にも外部アクセスを封じられます。
// src/domain/order.ts
import { Money } from "./money.ts";
import { orderId, type OrderId } from "./ids.ts";
export type OrderLine = { sku: string; unitPrice: Money; quantity: number };
export class Order {
readonly id: OrderId;
#lines: OrderLine[] = [];
#confirmed = false;
constructor(id: string) {
this.id = orderId(id);
}
addLine(sku: string, unitPrice: Money, quantity: number): void {
if (this.#confirmed) {
throw new Error("確定済みの受注に明細は追加できません");
}
if (!Number.isInteger(quantity) || quantity < 1) {
throw new RangeError("数量は1以上の整数です");
}
if (this.#lines.length >= 100) {
throw new RangeError("1受注あたりの明細は100行までです");
}
this.#lines.push({ sku, unitPrice, quantity });
}
total(): Money {
return this.#lines.reduce(
(sum, line) => sum.add(line.unitPrice.multiply(line.quantity)),
Money.of(0),
);
}
confirm(): void {
if (this.#lines.length === 0) {
throw new Error("明細のない受注は確定できません");
}
this.#confirmed = true;
}
get lines(): readonly OrderLine[] {
return this.#lines;
}
}
メソッド設計の判断基準は、業務上その操作が単独で起きうるかどうかです。「明細を1行追加する」は単独で起きるのでメソッドにする。一方「明細の単価だけを書き換える」が値引き承認とセットでしか起きないなら、単価のセッターを作らずapplyDiscount(approvalId, rate)のような業務語のメソッドにします。セッターを機械的に生やすと、後述の貧血症へ直行します。
集約をまたぐ更新をIDの参照と結果整合に置き換える判断基準と実装手順
受注と在庫を同一トランザクションで更新したい、という要求は必ず出ます。両方を1つの集約に入れると、受注1件の保存で在庫レコードまでロックすることになり、同時実行性が落ちます。判断基準は「その2つが同時に矛盾していると誰が困るか」。即時には誰も困らない(在庫引当が数秒遅れても回る)なら、集約は分けてください。
分けた場合、受注集約は在庫のIDだけを持ち、確定後にドメインイベントを発行して在庫側を更新します。読み取り側で両者を突き合わせて表示するなら、書き込みモデルとは別に参照専用モデルを用意する構成です。この分離の実装はCQRS(コマンドクエリ責務分離)の仕組みとRead Model/Write Modelの実装パターンで扱っています。
リポジトリの実装とドメイン層からインフラ依存を切り離す配置設計
リポジトリは、集約を丸ごと取得・保存する窓口です。ORMのモデルをそのままドメインで使うと、DBのカラム構成が業務ルールを縛り始めます。
リポジトリインターフェースをドメイン層に置く理由と実装の配置先
インターフェースはドメイン層に、実装はインフラ層に置きます。理由は依存の向きです。ドメイン層がインフラ層を参照すると、業務ルールのテストにDBが要ります。インフラ層がドメイン層のインターフェースを実装する形なら、ドメイン層は永続化の存在を知らずに済みます。
// src/domain/order-repository.ts(インターフェースはドメイン層)
import type { Order } from "./order.ts";
import type { OrderId } from "./ids.ts";
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
メソッドの粒度にも基準があります。findByCustomerAndStatusAndDateのような検索条件の組み合わせをリポジトリに生やし始めたら、それは参照用途であって集約の永続化ではありません。参照系は別のクエリサービスへ分けます。この依存の向きの考え方はクリーンアーキテクチャの4層構造と依存性逆転の解説と同じで、両者は矛盾なく併用できます。
node:testで集約の不変条件を検証する実行手順と、DBなしで回るテスト
リポジトリをインターフェースに切り出した結果、集約のテストはDBなしで書けます。Node.jsの組み込みテストランナー(node:test)なら追加インストールも不要です。
// src/domain/order.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { Money } from "./money.ts";
import { Order } from "./order.ts";
test("確定後の受注には明細を追加できない", () => {
const order = new Order("ORD-0001");
order.addLine("SKU-1", Money.of(1200), 2);
order.confirm();
assert.throws(() => order.addLine("SKU-2", Money.of(500), 1));
});
test("合計金額は明細から都度再計算される", () => {
const order = new Order("ORD-0002");
order.addLine("SKU-1", Money.of(1200), 2);
order.addLine("SKU-2", Money.of(500), 3);
assert.equal(order.total().amount, 3900);
});
test("明細が空の受注は確定できない", () => {
const order = new Order("ORD-0003");
assert.throws(() => order.confirm());
});
実行はnode --test src/domain/ だけで.test.tsが拾われます。型ストリッピングはv23.6.0およびv22.18.0で既定有効のため、トランスパイル工程を挟まない実行が可能です。押さえる対象は集約の不変条件(確定後は変更不可、明細は100行まで、空では確定不可)で、1つずつassert.throwsに落とします。ここが通れば、DB層を差し替えても業務ルールは壊れません。
既存の手続き型コードへDDDを段階導入する4ステップと着手順の根拠
新規開発でDDDを一から組む機会より、既存システムへ後から入れる状況のほうが実務では多くなります。全面書き直しは承認が下りません。動いているコードを止めずに部分置換していく段取りが要ります。
値オブジェクト先行で着手する理由と、集約から始めた場合の手戻り量
順序は、値オブジェクト、エンティティ、集約ルート、リポジトリの4ステップです。値オブジェクトを先に置く理由は、置き換えの影響範囲が呼び出し箇所の型だけで閉じるため。numberをMoneyに変えても、既存のサービス層はmoney.amountで従来値を取り出せるので、段階的に移行できます。
逆に集約から始めると手戻りが出ます。内部で扱う値が素のnumberやstringのままなので、集約の中に値の検証コードを書くことになるからです。その後に値オブジェクトを導入すれば、集約側に書いた検証を今度は削って回る作業が発生します。1集約あたり数十行の書き直しが、集約の数だけ積み上がります。
境界づけられたコンテキストの線引きを既存テーブルから逆算する手順
境界づけられたコンテキストは、Fowlerの定義では特定のモデルが有効な範囲を区切るものです。既存システムで線を引くときは、テーブル一覧からではなく、同じ名詞が別の意味で使われている箇所から探します。「顧客」が営業側では見込み先、請求側では請求先法人を指すなら、そこが境界の候補です。
手順は3つです。1つ目、業務担当者ごとに同じ用語の定義を書き出して食い違いを見つける。2つ目、その食い違いの単位でモデルを分け、コンテキスト内では用語の意味を1つに固定する。3つ目、コンテキスト間の連携を「どちらが上流か」の関係として決める。ddd-crewのBounded Context Canvasのようなテンプレートなら、この3工程を1枚に収めて担当者と合意できます。線引きを先に済ませようとせず、値オブジェクトの整備と並行で確定させるほうが、モデリング会議が空転しません。
型消去で使えない構文と、ドメイン貧血症に戻さないための実装ルール
TypeScriptでDDDを書くと、言語仕様側と設計側で1つずつ落とし穴があります。片方は実行時エラー、もう片方は「DDDの形だけになる」という静かな失敗です。
Nodeの型ストリッピングが弾く構文と、erasableSyntaxOnlyでの事前検出
Node.jsの型ストリッピングは型注釈を削るだけの処理なので、実行時のコード生成を伴う構文は扱えません。公式ドキュメントが非対応として挙げるのは、enum宣言・実行時コードを含むnamespace・パラメータプロパティ・import aliasesの4種類です。
| 書きたくなる構文 | DDDでの用途 | 型消去での可否 | 代替 |
|---|---|---|---|
| enum OrderStatus | 状態遷移の表現 | エラー | ユニオン型+as constのオブジェクト |
| constructor(readonly x:T) | 値オブジェクトの簡潔な定義 | エラー | フィールド宣言+本体で代入 |
| namespace(値を持つもの) | コンテキストごとの名前空間 | エラー | モジュール分割 |
| import type { X } | ドメイン型の参照 | 可 | — |
人力で避けるのは無理があるので、tsconfig側で機械検出します。erasableSyntaxOnlyを有効にすれば、型消去で処理できない構文がコンパイルエラーになります。公式docsの推奨設定は次のとおりです。
// tsconfig.json(型消去前提の推奨設定)
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}
型ストリッピングは2026年9月時点で安定版です。v25.2.0およびv24.12.0で昇格し、v26.0.0では--experimental-transform-typesフラグが削除されました。Nodeが22系より前の既存プロジェクトでは使えないため、ビルド工程を残す判断になります。
getterとsetterだけの実装がドメイン貧血症になる境目と判定方法
DDDの形をなぞりながら中身が伴わない状態を、FowlerはAnemic Domain Model(ドメインモデル貧血症)と呼びました。国内で「軽量DDD」と言われる状態も多くはこれです。クラス名だけがEntityやValueObjectになり、業務ルールはサービス層に残っています。
判定は機械的にできます。ドメイン層のクラスを開き、getterとsetter以外のpublicメソッドを数えてください。ゼロなら貧血症。1つ以上あっても、そのメソッドがsetStatusのような値の代入でしかないなら同じです。業務語のメソッド(confirm・cancel・applyDiscount)がいくつあるかが、モデルに血が通っているかの実質的な指標になります。
戻し方の順序も決まっています。サービス層のif文を読み、条件が「対象オブジェクトのフィールドだけで判定できるもの」を抽出してそのオブジェクトのメソッドへ移す。「他の集約の状態を見ている条件」はドメインサービスに残す。この仕分けを飛ばして全部をエンティティへ寄せると、今度はエンティティが他集約への参照を持ち始め、依存の向きが壊れます。
DDDを実装しない方が安いプロジェクト条件と、投資が回収できる分岐点
DDDは万能解ではなく、複雑さに対する投資です。Microsoft LearnのDDDおよびCQRSパターンの章も、対象を「複雑なサブシステム、または絶えず変化するビジネスルールを持つドメインエキスパートの知識から派生したマイクロサービス」に限定し、データ駆動のCRUD型サービスと書き分けています。
CRUD中心の管理画面にDDDを入れない判断と、その場合の代替設計
マスタ管理画面のように、入力値の形式チェック以外に業務ルールがない機能へDDDを入れる判断は誤りです。値オブジェクト・集約・リポジトリを敷くと1テーブルあたりのファイル数が1から5前後へ増え、見返りがありません。この種の機能はトランザクションスクリプトのまま書き、DTOとバリデーションスキーマだけ整えます。
同一システム内で方式を混在させても構いません。受注・請求・与信のような業務ルールが集中する領域だけDDDで組み、マスタ管理はCRUDのままにする構成が現実的です。「全モジュールで設計方式を統一する」判断のほうが割高になります。
ドメインエキスパートが不在の受託案件でDDD実装を見送る具体条件
受託開発で最も危険なのは、業務を答えられる担当者が発注側にいない状態でDDDを始めることです。ユビキタス言語は対話からしか作れず、窓口が仕様書の取り次ぎだけの体制ではモデルの根拠が推測になります。推測で作れば受け入れテストで業務ルールの誤りが発覚し、集約の再設計から手戻ります。
見送りの条件を具体的に置くと、次の3つが揃った案件では採用しません。業務を判断できる担当者との定例が確保できない、要件が確定済みで変更を想定していない、稼働後の改修予定がない。逆にこの3つのいずれかが崩れている(=業務担当者と話せる、ルールが変わり続ける、長期に手を入れる)なら投資は回収できます。判断に迷う規模の基幹系については、基幹システム開発の相談時に、対象業務のルール変更頻度を持ち込んでいただければ、DDDを敷く範囲とCRUDで済ませる範囲の線引きから設計できます。
実装工数が回収できる複雑度の目安と、途中でDDDへ切り替える判断時期
投資判断の目安になるのは、コード量ではなく業務ルールの分岐数と変更頻度です。1つの業務オブジェクトに対する状態遷移が5つを超え、かつ遷移条件が年に数回変わるなら、遷移ロジックをエンティティに閉じ込める価値が出ます。状態が2〜3で数年変わらないなら、ステータス列とif文で十分。
途中からの切り替え時期は、「同じ業務ルールの修正が3箇所以上に及ぶ」現象が観測できた時点です。値の検証やステータス判定が複数サービスへ複製されている状態は、集約に閉じ込める対象が見えているサインになります。その現象が起きる前の先回り導入は、使われない抽象を作るだけ。まず値オブジェクトだけ入れ、複製が観測された領域から集約化する進め方が、回収可能性の高い順序です。
よくある質問
DDDの実装に着手する段階で問い合わせの多い論点を、判断できる粒度で答えます。
DDDとは何の略ですか?
Domain-Driven Designの略で、日本語ではドメイン駆動設計と呼びます。2003年にEric Evansが提唱した設計手法で、業務領域(ドメイン)の知識をソフトウェアの構造へ反映させることを中心に据えた手法です。パターン定義は境界づけられたコンテキストやユビキタス言語といった戦略側と、値オブジェクト・エンティティ・集約といった戦術側に分かれています。用語ごとの意味はドメイン駆動設計(DDD)とは?意味・基本用語・実装と採用判断で整理しました。
DDDの実装にオブジェクト指向言語は必須ですか?
必須ではありません。ただし集約ルート経由でしか内部を触らせない制約を言語機能で守れるほうが実装は堅くなります。TypeScriptならプライベートフィールド、Javaならアクセス修飾子。関数型言語でも、モジュール境界で公開する関数を絞れば同じ効果を得られます。判断基準はパラダイムではなく「集約の外から内部状態を直接書き換える経路を塞げるか」で、塞げない構成では規約とレビュー頼みになり、人員が入れ替わると崩れます。
DDDとクリーンアーキテクチャは何が違いますか?
扱う対象が違います。DDDは業務知識をどうモデルへ写すかという設計手法、クリーンアーキテクチャは依存の向きをどう制御するかというレイヤ構成の指針です。競合せず、実務では併用します。リポジトリのインターフェースをドメイン層に置き実装をインフラ層に置く構成は、両者が重なる箇所です。層構成の詳細はクリーンアーキテクチャの4層構造と依存性逆転の解説を参照してください。
小規模なチームでもDDDを実装できますか?
人数より、業務担当者と話せるかどうかで決まります。3人のチームでも発注側の業務担当と週次で会話できればモデルは育ちます。20人体制でも仕様書の受け渡しだけならユビキタス言語は作れません。小規模で始めるなら全機能へ適用せず、業務ルールが集中する1〜2の集約に限定してください。値オブジェクトの導入だけなら1人日程度から着手できます。
軽量DDDと本来のDDDの違いは何ですか?
軽量DDDは、値オブジェクトやエンティティといった戦術パターンだけを取り入れ、ユビキタス言語や境界づけられたコンテキストの検討を省いた状態を指します。クラス名は設計手法に沿っているのに業務ルールはサービス層に残るため、Fowlerの言うドメインモデル貧血症の状態です。見分け方は前述のとおりで、ドメイン層のクラスにgetterとsetter以外の業務語メソッドがいくつあるかを数えれば判定できます。
関連記事
- ドメイン駆動設計(DDD)とは?意味・基本用語・実装と採用判断をわかりやすく解説:本記事が省いた用語定義と採用判断の全体像。
- CQRS(コマンドクエリ責務分離)とは?仕組み・Read Model/Write Modelと実装パターンを解説:集約をまたぐ参照を分離する実装パターン。
- クリーンアーキテクチャとは?4層構造・SOLID原則・依存性逆転を実装目線で解説:リポジトリの配置とレイヤ間の依存の向き。
- イベントソーシングとは?状態再生とイベントストア設計を実装視点で解説【2026年版】:集約の状態をイベント列で保持する設計。
- ユースケース駆動開発とは?ICONIXの4工程とロバストネス分析・DDDとの違いを解説:モデリングの進め方を別手法と比較。