DDD
ドメイン駆動設計(DDD)徹底解説 — 戦略・戦術・アーキテクチャ・実装
1. はじめに
ドメイン駆動設計(Domain-Driven Design、以下 DDD)は、Eric Evans が 2003 年に発表した同名の書籍に端を発する、複雑な業務領域(ドメイン)を扱うソフトウェアを設計・実装するためのアプローチである。DDD は単なる「設計パターン集」ではなく、ソフトウェア開発の中心にドメインの理解を据え、ドメインエキスパートと開発者が共通の言葉で語り合い、その言葉をコードにそのまま反映することを目指す思想・方法論である。
本記事では、DDD の歴史的背景から始め、戦略的設計(ユビキタス言語、境界づけられたコンテキスト、コンテキストマッピング、サブドメイン)、戦術的設計(エンティティ、値オブジェクト、集約、リポジトリ、ドメインサービス、ドメインイベント、仕様パターン)を順に詳述する。さらに、ヘキサゴナル / オニオン / クリーンアーキテクチャといった全体アーキテクチャとの関係、CQRS / Event Sourcing / Saga といったイベント駆動的な設計との接続、マイクロサービス時代における DDD の意味、そして具体的な TypeScript による実装例、テスト戦略、よくあるアンチパターン、導入ステップ、ツールとエコシステムまで、約 30 ページ相当の分量でカバーする。
1.1 DDD が解決する問題
複雑な業務システム(保険、金融、医療、物流、EC、製造など)を開発するとき、開発者は次のような困難に直面する。
- 業務知識のサイロ化:業務ルールがドキュメント・SQL・ストアドプロシージャ・UI の検証ロジック・バッチ処理に分散しており、誰も全体像を把握していない。
- 言葉のずれ:営業が言う「顧客」、配送が言う「顧客」、経理が言う「顧客」がそれぞれ別の概念であるにもかかわらず、データベースの
customerテーブル一つで無理やり表現されている。 - 貧血ドメインモデル(Anemic Domain Model):エンティティはただの getter/setter の塊になり、ビジネスロジックはサービス層(しばしば数千行の god class)に流出している。
- ビッグボール・オブ・マッド:機能追加のたびに副作用が読めず、リファクタリングのコストが指数関数的に増加する。
DDD は、これらの問題をドメインに対する深い洞察を中心に据え、ソフトウェアの構造をドメインの構造に一致させることで解決しようとする。
1.2 本記事の対象読者
- 中規模〜大規模のエンタープライズシステムを設計するアーキテクト・テックリード
- マイクロサービスへの分割境界を真剣に考えたいエンジニア
- 「クリーンアーキテクチャの図は知っているが、本当にエンティティと値オブジェクトを使いこなしているか不安」と感じている開発者
- レガシー資産を漸進的に近代化したいチーム
DDD は銀の弾丸ではない。CRUD だけで十分なシステムや、ドメインの複雑さよりも UI の複雑さが支配的なシステムには過剰投資となりうる。「ドメインの複雑さこそが本質的な複雑さ」であるシステムに対してこそ、DDD は最大の効果を発揮する。
2. DDD の歴史と背景
2.1 Blue Book(2003)
DDD という用語と体系を確立したのは、Eric Evans による『Domain-Driven Design: Tackling Complexity in the Heart of Software』(通称 Blue Book)である。当時は J2EE と Hibernate が普及し始めた時期で、Evans は ORM が技術的詳細をうまく隠蔽してくれる時代になったからこそ、**「次に解決すべき複雑さはドメイン側にある」**と主張した。
Blue Book の構成は次の通りである。
- 第 I 部:ドメインモデルを動かす(モデル駆動設計)
- 第 II 部:モデル駆動設計の構成要素(戦術的設計)
- 第 III 部:より深い洞察へ向けたリファクタリング(Supple Design)
- 第 IV 部:戦略的設計(境界づけられたコンテキスト、コンテキストマップ、蒸留)
特に第 IV 部の戦略的設計は、当初は応用編という位置づけだったが、マイクロサービス時代になって最も重要視される領域となった。
2.2 Red Book(2013)
10 年後の 2013 年、Vaughn Vernon が『Implementing Domain-Driven Design』(通称 Red Book)を出版した。Red Book は Blue Book を実装寄りに翻訳した書籍で、次のような点で実務上の指針を強化した。
- 集約(Aggregate)の設計ルールの明確化(「小さく保て」「他の集約は ID 参照せよ」「1 トランザクション 1 集約」)
- ドメインイベントを一級概念として扱う方針
- ヘキサゴナルアーキテクチャや CQRS との接続
- REST、メッセージング、結果整合性の現実的な扱い方
2.3 IDDD 以降〜マイクロサービスとの融合
2014 年以降、Sam Newman の『Building Microservices』、Adam Tornhill のコード分析、Alberto Brandolini の Event Storming など、DDD は周辺手法と相互作用しながら発展した。特に Bounded Context(境界づけられたコンテキスト)= マイクロサービスの境界 という視点は、現代のシステム分割の事実上の出発点となっている。
2010 年代後半には Vlad Khononov の『Learning Domain-Driven Design』、Nick Tune や Susanne Kaiser によるソシオテクニカルな DDD 解釈が登場し、組織構造(チーム)とドメイン境界の整合性(コンウェイの法則の積極的活用、Inverse Conway Maneuver)が中心テーマとなった。
2.4 Clean Architecture / Hexagonal との関係
DDD が提唱するドメイン中心設計は、Alistair Cockburn の Hexagonal Architecture(2005)、Jeffrey Palermo の Onion Architecture(2008)、Robert C. Martin の Clean Architecture(2012) と本質的に同じ目標を共有している。それは、
「ビジネスロジックをフレームワーク・UI・DB から独立させ、依存の方向を内側(ドメイン)に向ける」
という原則である。これらは表記法やレイヤー数こそ異なるが、いずれも DI と依存関係逆転の原則(DIP)によってインフラ詳細をドメインから追い出すための具体的構造を提供する。DDD はこれらのアーキテクチャと組み合わせて使われるのが一般的で、本記事でも第 5 章でその関係を詳しく取り上げる。
3. 戦略的設計(Strategic Design)
戦略的設計は、システム全体をどのように分割し、どの部分にどれだけの労力を投じるかを決める活動である。DDD の長期的な価値の大半はここから生まれる。
3.1 ユビキタス言語(Ubiquitous Language)
ユビキタス言語とは、ドメインエキスパートと開発者が同じ意味で用いる業務語彙のことである。仕様書・会議の発言・コード・テスト名・データベース列名・ログメッセージのすべてが、同じ語彙で書かれていなければならない。
たとえば EC ドメインで「注文」を表す概念について、
- ドキュメント:「注文」
- コード:
OrderRecord - DB:
t_purchase_header - 営業の発言:「受注」
のように複数の名前が混在すると、新人エンジニアは概念のマッピングに膨大な時間を費やすことになる。DDD では、同じ概念には常に同じ名前を、異なる概念には決して同じ名前を使わない。これは些細に見えて、長期的な保守性を最も劇的に改善する原則である。
ユビキタス言語は最初から完璧である必要はない。会話や設計ワークショップ(後述する Event Storming など)の中で発見・洗練されていく生きた語彙であり、変化を恐れず、用語が変わったらコードもただちに改名する規律が重要である。
3.2 境界づけられたコンテキスト(Bounded Context)
ある語彙が一貫した意味を持つ範囲を Bounded Context と呼ぶ。先述の「顧客」が営業・配送・経理で別の意味を持つなら、それぞれを別の Bounded Context として分離し、それぞれの内部で「顧客」という用語が一意の意味を持つようにする。
Bounded Context は、
- ソースコードリポジトリの境界
- マイクロサービスの境界
- チームの責任範囲
- DB スキーマの所有境界
と一致するのが理想である。境界をまたいでデータを共有する場合は、翻訳層(後述する ACL)を介してやり取りする。
3.3 コンテキストマッピング(Context Mapping)
Context Map は、複数の Bounded Context 間の関係を可視化する図である。代表的な関係パターンは次の通り。
| パターン | 説明 |
|---|---|
| Shared Kernel | 二つのコンテキストが小さなモデルを共有する。変更には両チームの合意が必要。 |
| Customer / Supplier | 上流(供給者)と下流(顧客)。下流のニーズが上流の優先順位に影響を与える協調関係。 |
| Conformist | 下流が上流のモデルにそのまま従う。上流が協調的でないか、上流の変更コストが高すぎる場合に選択。 |
| Anticorruption Layer (ACL) | 下流が上流のモデルを翻訳・隔離するレイヤを設ける。レガシー連携の定番。 |
| Open Host Service (OHS) | 上流が公開された API を提供し、複数の下流が同じインターフェイスを使う。 |
| Published Language | OHS が標準化されたフォーマット(JSON Schema、Avro、protobuf)で公開する語彙。 |
| Separate Ways | 統合しない。コストに見合わなければ切り離す勇気を持つ。 |
| Big Ball of Mud | 既存の混沌。意図的に隔離(ACL で囲む)する以外、対処は難しい。 |
| Partnership | 二つのコンテキストが運命共同体として共に進化する。 |
3.4 サブドメイン:Core / Supporting / Generic
ドメインは性質によって 3 種類に分類される。
- コアドメイン(Core Domain):競争優位の源泉。最も優秀なエンジニアと最も多くの時間を投資する。
- 支援サブドメイン(Supporting Subdomain):コアの周辺にあり業務固有だが、競争優位ではない。内製するが投資は控えめ。
- 汎用サブドメイン(Generic Subdomain):認証、通知、決済、PDF 生成など、世のどの企業でも同じ。買う/借りる/OSS を使うを優先し、決して内製しない。
DDD の最も重要な戦略的判断の一つは、「どこがコアか」を見極めることである。コアでないものに DDD の戦術的パターンを総動員すると、過剰設計になる。
3.5 ドメインビジョン声明(Domain Vision Statement)
コアドメインに対しては、1 段落程度の短い「ビジョン声明」を作るのが望ましい。これは「何のために」「誰のために」「他とどう違うのか」を 3〜5 文で表現する文書で、設計判断のテストオラクルとして機能する。
4. 戦術的設計(Tactical Design)
戦術的設計は、ある一つの Bounded Context の内部をどのようにコードへ落とし込むかを扱う。本章ではすべての例を TypeScript で示す。
4.1 値オブジェクト(Value Object)
値オブジェクトは、識別子を持たず、属性の値そのものによって等価性が決まる不変オブジェクトである。金額・住所・期間・色・座標・メールアドレスなどはすべて値オブジェクトとしてモデル化すべき候補である。
// src/domain/shared/Money.ts
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string,
) {
if (!Number.isFinite(amount)) throw new Error("amount must be finite");
if (amount < 0) throw new Error("amount must be non-negative");
if (!/^[A-Z]{3}$/.test(currency)) throw new Error("invalid ISO 4217 code");
Object.freeze(this);
}
static of(amount: number, currency: string): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(`currency mismatch: ${this.currency} vs ${other.currency}`);
}
}
}
ポイントは次の通り。
private constructorと静的ファクトリで生成を制御する。- 不変条件(amount は非負・通貨は ISO コード)はコンストラクタで検証する。生成された時点で常に正しい状態を保証する。
addなどの操作は新しいインスタンスを返す(破壊的変更しない)。- 等価性は属性によって決定する(
equalsを必ず実装する)。
4.2 エンティティ(Entity)
エンティティは、識別子(ID)によって等価性が決まるオブジェクトであり、ライフサイクルを通じて状態が変化する。
// src/domain/order/OrderItem.ts
import { Money } from "../shared/Money";
export class OrderItem {
constructor(
public readonly id: string,
public readonly productId: string,
private _quantity: number,
private _unitPrice: Money,
) {
if (_quantity <= 0) throw new Error("quantity must be positive");
}
get quantity(): number { return this._quantity; }
get unitPrice(): Money { return this._unitPrice; }
changeQuantity(newQuantity: number): void {
if (newQuantity <= 0) throw new Error("quantity must be positive");
this._quantity = newQuantity;
}
subtotal(): Money {
return this._unitPrice.multiply(this._quantity);
}
equals(other: OrderItem): boolean {
return this.id === other.id;
}
}
エンティティの実装上の注意点。
- id は不変。変更されたら別のエンティティになってしまう。
- 状態変更は必ず意味のあるメソッド(
changeQuantityなど)を介して行う。set quantity()ではなく、ドメイン上の意味を持つ動詞で書く。 - 不変条件はメソッドの開始時にチェックし、違反する操作は 例外(または Result 型)で拒否する。
4.3 集約(Aggregate)と集約ルート(Aggregate Root)
集約とは、一貫性境界を共有する複数のエンティティ・値オブジェクトの塊である。集約の入り口は集約ルートと呼ばれる単一のエンティティで、外部からは集約ルート経由でしかアクセスできない。
集約の設計ルール(Vernon の「集約設計の有効なルール」):
- 集約は小さく保て。理想は集約ルート + 少数の値オブジェクト。
- 他の集約は ID 参照のみ。直接オブジェクト参照を持たない。
- 1 トランザクション = 1 集約の更新。複数の集約をまたぐ整合性は結果整合性(イベント駆動)で扱う。
- 不変条件は集約境界の内側で守られる。
// src/domain/order/Order.ts
import { Money } from "../shared/Money";
import { OrderItem } from "./OrderItem";
import { DomainEvent } from "../shared/DomainEvent";
import { OrderPlaced } from "./events/OrderPlaced";
export type OrderStatus = "Draft" | "Placed" | "Paid" | "Shipped" | "Cancelled";
export class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = "Draft";
private _events: DomainEvent[] = [];
constructor(
public readonly id: string,
public readonly customerId: string,
public readonly currency: string,
) {}
addItem(item: OrderItem): void {
if (this._status !== "Draft") {
throw new Error("cannot modify a placed order");
}
if (item.unitPrice.currency !== this.currency) {
throw new Error("currency mismatch");
}
const existing = this._items.find((i) => i.productId === item.productId);
if (existing) {
existing.changeQuantity(existing.quantity + item.quantity);
return;
}
this._items.push(item);
}
removeItem(productId: string): void {
if (this._status !== "Draft") {
throw new Error("cannot modify a placed order");
}
this._items = this._items.filter((i) => i.productId !== productId);
}
total(): Money {
return this._items
.map((i) => i.subtotal())
.reduce((acc, cur) => acc.add(cur), Money.of(0, this.currency));
}
place(): void {
if (this._status !== "Draft") {
throw new Error("order already placed");
}
if (this._items.length === 0) {
throw new Error("cannot place an empty order");
}
this._status = "Placed";
this._events.push(new OrderPlaced(this.id, this.customerId, this.total()));
}
cancel(): void {
if (this._status === "Shipped") {
throw new Error("cannot cancel a shipped order");
}
this._status = "Cancelled";
}
get status(): OrderStatus { return this._status; }
get items(): readonly OrderItem[] { return this._items; }
pullEvents(): DomainEvent[] {
const e = this._events;
this._events = [];
return e;
}
}
この Order 集約は、**「Draft 状態でのみ商品を追加・削除できる」「Shipped になったらキャンセルできない」「通貨は集約全体で一致する」「空注文は確定できない」**といった不変条件を、すべて内部で守っている。アプリケーションサービスが直接 _items を弄ることはできない。
4.4 リポジトリ(Repository)
リポジトリは、集約の永続化と再構築を担う、ドメインから見たコレクション風インターフェイスである。インターフェイスはドメイン層に、実装はインフラ層に置く。
// src/domain/order/OrderRepository.ts
import { Order } from "./Order";
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
nextIdentity(): string;
}
実装側(PostgreSQL の例):
// src/infrastructure/persistence/PostgresOrderRepository.ts
import { Pool } from "pg";
import { randomUUID } from "crypto";
import { Order } from "../../domain/order/Order";
import { OrderItem } from "../../domain/order/OrderItem";
import { OrderRepository } from "../../domain/order/OrderRepository";
import { Money } from "../../domain/shared/Money";
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
nextIdentity(): string {
return randomUUID();
}
async findById(id: string): Promise<Order | null> {
const orderRow = await this.pool.query(
"SELECT id, customer_id, currency, status FROM orders WHERE id=$1",
[id],
);
if (orderRow.rowCount === 0) return null;
const r = orderRow.rows[0];
const order = new Order(r.id, r.customer_id, r.currency);
const items = await this.pool.query(
"SELECT id, product_id, quantity, unit_price FROM order_items WHERE order_id=$1",
[id],
);
for (const i of items.rows) {
order.addItem(new OrderItem(
i.id, i.product_id, i.quantity, Money.of(Number(i.unit_price), r.currency),
));
}
// 注意: status を直接復元する場合は、ファクトリメソッドや内部 reconstitute API を用意する
return order;
}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query(
`INSERT INTO orders(id, customer_id, currency, status)
VALUES ($1,$2,$3,$4)
ON CONFLICT (id) DO UPDATE SET status=$4`,
[order.id, order.customerId, order.currency, order.status],
);
await client.query("DELETE FROM order_items WHERE order_id=$1", [order.id]);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items(id, order_id, product_id, quantity, unit_price)
VALUES ($1,$2,$3,$4,$5)`,
[item.id, order.id, item.productId, item.quantity, item.unitPrice.amount],
);
}
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
}
ポイント:
- インターフェイスはドメインから見た集合操作の抽象。
SELECT * FROM orders WHERE total > 1000のような SQL 寄りメソッドを生やすのは原則アンチパターン。 - どうしても複雑な検索が必要なら、後述する Specification や、書き込みと別の Read Model(CQRS) に切り出す。
4.5 ドメインサービス(Domain Service)
ドメインの操作のうち、特定の集約に自然に属さないものはドメインサービスとして切り出す。たとえば、複数の集約をまたいで処理する計算や、外部のドメイン的契約に基づく判定など。
// src/domain/pricing/DiscountPolicy.ts
import { Order } from "../order/Order";
import { Money } from "../shared/Money";
export interface DiscountPolicy {
apply(order: Order): Money;
}
export class TenPercentOffOver10000 implements DiscountPolicy {
apply(order: Order): Money {
const total = order.total();
if (total.amount >= 10000) {
return Money.of(total.amount * 0.1, total.currency);
}
return Money.of(0, total.currency);
}
}
ドメインサービスはステートレスで、入力(集約・値オブジェクト)と出力(値オブジェクト)の純粋関数として書くことが多い。
4.6 アプリケーションサービス(Application Service)
アプリケーションサービスは、ユースケースの調整役である。トランザクション境界を引き、リポジトリで集約を取り出し、ドメインオブジェクトのメソッドを呼び、保存し、ドメインイベントをパブリッシュする。ビジネスルールは書かず、調整のみを行う点が重要。
// src/application/order/PlaceOrderService.ts
import { OrderRepository } from "../../domain/order/OrderRepository";
import { DomainEventPublisher } from "../../domain/shared/DomainEventPublisher";
export class PlaceOrderService {
constructor(
private readonly orders: OrderRepository,
private readonly publisher: DomainEventPublisher,
) {}
async execute(orderId: string): Promise<void> {
const order = await this.orders.findById(orderId);
if (!order) throw new Error(`order ${orderId} not found`);
order.place(); // ドメインの不変条件はここで守られる
await this.orders.save(order);
await this.publisher.publishAll(order.pullEvents());
}
}
4.7 ファクトリ(Factory)
複雑な集約を生成するロジックは、ファクトリに集約させる。コンストラクタで複雑な初期化をすると、生成途中の不完全な状態を許容してしまうリスクがあるためである。
// src/domain/order/OrderFactory.ts
import { Order } from "./Order";
import { OrderItem } from "./OrderItem";
import { Money } from "../shared/Money";
import { OrderRepository } from "./OrderRepository";
export class OrderFactory {
constructor(private readonly repo: OrderRepository) {}
newOrder(
customerId: string,
currency: string,
lines: { productId: string; quantity: number; unitPriceAmount: number }[],
): Order {
const order = new Order(this.repo.nextIdentity(), customerId, currency);
for (const l of lines) {
order.addItem(new OrderItem(
this.repo.nextIdentity(),
l.productId,
l.quantity,
Money.of(l.unitPriceAmount, currency),
));
}
return order;
}
}
4.8 ドメインイベント(Domain Event)
ドメインイベントは、ドメインで「起きた」事実を表す不変オブジェクト。OrderPlaced、PaymentReceived、InventoryReserved のように、過去形で命名する。
// src/domain/shared/DomainEvent.ts
export interface DomainEvent {
readonly occurredAt: Date;
readonly name: string;
}
// src/domain/order/events/OrderPlaced.ts
import { Money } from "../../shared/Money";
import { DomainEvent } from "../../shared/DomainEvent";
export class OrderPlaced implements DomainEvent {
readonly name = "OrderPlaced";
readonly occurredAt = new Date();
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly total: Money,
) {}
}
イベントは集約の中で蓄積し、保存後にディスパッチされる(Outbox パターンで確実に配送する)。
4.9 仕様パターン(Specification)
ある条件をオブジェクトとして表現するパターン。検索条件・ビジネスルール・バリデーションのいずれにも使える。
// src/domain/shared/Specification.ts
export interface Specification<T> {
isSatisfiedBy(candidate: T): boolean;
and(other: Specification<T>): Specification<T>;
or(other: Specification<T>): Specification<T>;
not(): Specification<T>;
}
export abstract class CompositeSpecification<T> implements Specification<T> {
abstract isSatisfiedBy(candidate: T): boolean;
and(other: Specification<T>): Specification<T> { return new AndSpec(this, other); }
or(other: Specification<T>): Specification<T> { return new OrSpec(this, other); }
not(): Specification<T> { return new NotSpec(this); }
}
class AndSpec<T> extends CompositeSpecification<T> {
constructor(private a: Specification<T>, private b: Specification<T>) { super(); }
isSatisfiedBy(c: T): boolean { return this.a.isSatisfiedBy(c) && this.b.isSatisfiedBy(c); }
}
class OrSpec<T> extends CompositeSpecification<T> {
constructor(private a: Specification<T>, private b: Specification<T>) { super(); }
isSatisfiedBy(c: T): boolean { return this.a.isSatisfiedBy(c) || this.b.isSatisfiedBy(c); }
}
class NotSpec<T> extends CompositeSpecification<T> {
constructor(private a: Specification<T>) { super(); }
isSatisfiedBy(c: T): boolean { return !this.a.isSatisfiedBy(c); }
}
具体仕様の例:
import { CompositeSpecification } from "../shared/Specification";
import { Order } from "./Order";
export class HighValueOrderSpec extends CompositeSpecification<Order> {
constructor(private readonly threshold: number) { super(); }
isSatisfiedBy(order: Order): boolean {
return order.total().amount >= this.threshold;
}
}
5. アーキテクチャ全体像
DDD のパターンを最大限活かすには、ドメインを中心に置きインフラを外側に追いやるアーキテクチャを採用する必要がある。本章では代表的な 4 つの形を比較する。
5.1 古典的レイヤードアーキテクチャ
Blue Book で最初に紹介された素朴な構成。
┌──────────────────────────────┐
│ User Interface │
├──────────────────────────────┤
│ Application │
├──────────────────────────────┤
│ Domain │
├──────────────────────────────┤
│ Infrastructure │
└──────────────────────────────┘
依存方向は上から下へ。問題は、Domain 層がしばしば Infrastructure を参照したくなることである(たとえばリポジトリの実装、メール送信など)。これを許すと、ドメインがフレームワークと密結合する。
5.2 ヘキサゴナル / ポート & アダプタ
Alistair Cockburn が提案した形。ドメインを中心の六角形として描き、外部との接点を「ポート(インターフェイス)」と「アダプタ(実装)」に分離する。
依存はすべて外側 → 内側へ向かう。ドメインはどんな技術的詳細も知らない。
5.3 オニオンアーキテクチャ
Jeffrey Palermo がヘキサゴナルを同心円として描き直したもの。
┌──────────────────────────────────────┐
│ Infrastructure / UI │
│ ┌──────────────────────────────┐ │
│ │ Application Services │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ Domain Services │ │ │
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ Domain Model │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
最も内側は純粋なドメインモデル、その外がドメインサービス、さらに外がアプリケーションサービス、一番外側がインフラ/UI/フレームワーク。依存方向は外から内へのみ。
5.4 クリーンアーキテクチャ
Robert C. Martin が同心円を 4 層(Entities / Use Cases / Interface Adapters / Frameworks & Drivers)として整理したもの。本質はオニオン/ヘキサゴナルと同じだが、**「Use Case 層」**を明示的に分離して名付けた点が貢献である。
これら 3 つは **「ドメインを中心にし、依存を内側に向ける」**という同じ考え方を異なる絵で表現しているに過ぎない。重要なのは絵の選択ではなく、依存方向の規律を守ることである。
5.5 依存関係逆転の原則(DIP)
ドメイン層が「DB に保存したい」「メールを送りたい」と思ったら、インターフェイスをドメイン層に定義し、実装をインフラ層に置く。アプリケーションの起動時に DI コンテナが両者を結びつける。
// ドメイン層
export interface OrderRepository { /* ... */ }
export interface DomainEventPublisher { publishAll(events: DomainEvent[]): Promise<void>; }
// アプリケーション層
export class PlaceOrderService {
constructor(
private readonly orders: OrderRepository, // ← ドメイン層のインターフェイス
private readonly publisher: DomainEventPublisher, // ← ドメイン層のインターフェイス
) {}
}
// インフラ層
export class PostgresOrderRepository implements OrderRepository { /* ... */ }
export class KafkaDomainEventPublisher implements DomainEventPublisher { /* ... */ }
この構造により、テストではインメモリ実装を差し込むだけで純粋なユニットテストが書ける。
6. イベント駆動・CQRS との関係
6.1 ドメインイベントの活用
ドメインイベントは、集約間の結果整合性を実現するための主要な手段である。OrderPlaced を発行 → 在庫サービスが InventoryReserved を発行 → 決済サービスが PaymentRequested を発行 → ……というように、複数の Bounded Context が疎結合に連携する。
確実な配送には Outbox パターンが必須である。集約の保存と同じトランザクションでイベントを outbox テーブルへ書き込み、別プロセスがそれを Kafka 等にリレーする。
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
// アプリケーションサービス内で同一トランザクションで保存
async execute(orderId: string) {
return this.uow.run(async (tx) => {
const order = await this.orders.findById(orderId);
order.place();
await this.orders.save(order, tx);
for (const e of order.pullEvents()) {
await this.outbox.append(e, tx);
}
});
}
6.2 CQRS(Command Query Responsibility Segregation)
CQRS は、書き込みモデル(Command)と読み取りモデル(Query)を分離するパターン。書き込み側は集約と不変条件で守られたドメインモデル、読み取り側はユースケース/画面に最適化された投影(Projection)。
利点:
- 読み取りパフォーマンスを書き込みと独立に最適化できる(複雑な JOIN を事前に投影)
- ドメインモデルが「画面に必要なフィールドが足りない」ことに引きずられない
- 読み取りはノーマライズ済みの NoSQL や Elasticsearch にも置きやすい
注意:CQRS は全ドメインに適用すべきものではない。複雑度が低い領域では単一モデルで十分。
6.3 Event Sourcing
集約の状態を最新の値として保存するのではなく、ドメインイベントの追記ログとして保存する。集約は起動時にイベントをリプレイして状態を復元する。
abstract class EventSourcedAggregate {
private uncommitted: DomainEvent[] = [];
protected apply(event: DomainEvent): void {
this.handle(event);
this.uncommitted.push(event);
}
protected abstract handle(event: DomainEvent): void;
loadFromHistory(history: DomainEvent[]) {
history.forEach((e) => this.handle(e));
}
pullEvents(): DomainEvent[] {
const e = this.uncommitted; this.uncommitted = []; return e;
}
}
利点は完全な監査ログ・タイムトラベルデバッグ・新しい投影の遡及生成。欠点はクエリの複雑化、スキーマ進化(Event Versioning)、運用コスト。コアドメインのうちで履歴的価値が高い部分にのみ適用するのが現実的。
6.4 Saga / Process Manager
複数の Bounded Context にまたがるプロセスは、分散トランザクションの代わりに Saga(補償を伴う一連のステップ)で実装する。
実装スタイルは コレオグラフィ(自律的なイベント連鎖) と オーケストレーション(中央の Process Manager) の二択。複雑なフローはオーケストレーションのほうが追跡しやすい。
7. マイクロサービスと DDD
7.1 Bounded Context = Service Boundary
マイクロサービスの「適切な大きさ」は、技術的な指標ではなくドメイン上の指標で決める。1 つの Bounded Context = 1 つのマイクロサービスを出発点とする。これにより、
- 各サービスは独立した語彙と DB を持つ
- チームはサービス内のドメインを深く理解できる
- サービス間連携は Context Map に従う
逆に、Bounded Context を無視してテーブル粒度で分割すると、いわゆる分散モノリスになる。サービス境界をまたぐトランザクションが頻発し、結合度はモノリスよりかえって悪化する。
7.2 Anti-Corruption Layer(ACL)の重要性
外部サービス(特にレガシーや SaaS)と連携するときは、自分たちのドメインの語彙を守るために ACL を必ず設ける。
// 外部 CRM の Customer 表現
interface ExternalCrmCustomer {
cust_no: string;
cust_nm: string;
status_cd: number; // 1: active, 2: inactive
addr_zip: string;
addr_addr: string;
}
// ドメイン側のモデル
import { Customer, CustomerStatus, Address, ZipCode } from "../../domain/customer";
export class CrmCustomerTranslator {
toDomain(src: ExternalCrmCustomer): Customer {
return new Customer(
src.cust_no,
src.cust_nm,
this.mapStatus(src.status_cd),
new Address(src.addr_addr, new ZipCode(src.addr_zip)),
);
}
private mapStatus(code: number): CustomerStatus {
switch (code) {
case 1: return "Active";
case 2: return "Inactive";
default: throw new Error(`unknown status: ${code}`);
}
}
}
ACL を介することで、外部の構造変更がドメインへ波及するのを防ぐ。
7.3 サービス間連携の選択肢
| パターン | 用途 | トレードオフ |
|---|---|---|
| 同期 REST/gRPC | 即時応答が必要 | 結合度・障害伝搬・レイテンシ累積 |
| 非同期メッセージ | 結果整合性 OK | 複雑なトレース・順序保証 |
| API Composition | 集約系の照会 | 性能・依存方向 |
| CQRS Read Model | 高頻度読み取り | データ複製・整合性 |
DDD のスタンスは、**「同じ Bounded Context 内では強整合」「Bounded Context を跨ぐと結果整合」**である。
8. 実装例:EC ドメイン(NestJS + TypeScript)
ここまでの要素を統合した、注文 Bounded Context の最小実装例を示す。
8.1 ディレクトリ構成
src/
├── domain/
│ ├── shared/
│ │ ├── Money.ts
│ │ ├── DomainEvent.ts
│ │ ├── DomainEventPublisher.ts
│ │ └── Specification.ts
│ ├── customer/
│ │ ├── Customer.ts
│ │ ├── CustomerId.ts
│ │ └── CustomerRepository.ts
│ └── order/
│ ├── Order.ts
│ ├── OrderItem.ts
│ ├── OrderId.ts
│ ├── OrderRepository.ts
│ ├── OrderFactory.ts
│ └── events/
│ ├── OrderPlaced.ts
│ └── OrderCancelled.ts
├── application/
│ └── order/
│ ├── PlaceOrderService.ts
│ ├── CreateDraftOrderService.ts
│ └── CancelOrderService.ts
├── infrastructure/
│ ├── persistence/
│ │ ├── PostgresOrderRepository.ts
│ │ └── PostgresCustomerRepository.ts
│ ├── messaging/
│ │ └── KafkaDomainEventPublisher.ts
│ └── di/
│ └── OrderModule.ts
└── interfaces/
└── http/
└── OrderController.ts
8.2 Customer 集約と値オブジェクト
// src/domain/customer/CustomerId.ts
export class CustomerId {
constructor(public readonly value: string) {
if (!/^[0-9a-f-]{36}$/.test(value)) throw new Error("invalid CustomerId");
}
equals(other: CustomerId): boolean { return this.value === other.value; }
}
// src/domain/customer/Customer.ts
import { CustomerId } from "./CustomerId";
export class Customer {
constructor(
public readonly id: CustomerId,
public readonly displayName: string,
private _creditLimit: number,
) {}
get creditLimit() { return this._creditLimit; }
raiseCreditLimit(amount: number) {
if (amount <= 0) throw new Error("amount must be positive");
this._creditLimit += amount;
}
}
8.3 アプリケーションサービス
// src/application/order/CreateDraftOrderService.ts
import { OrderRepository } from "../../domain/order/OrderRepository";
import { OrderFactory } from "../../domain/order/OrderFactory";
export interface CreateDraftOrderInput {
customerId: string;
currency: string;
lines: { productId: string; quantity: number; unitPrice: number }[];
}
export class CreateDraftOrderService {
constructor(
private readonly factory: OrderFactory,
private readonly orders: OrderRepository,
) {}
async execute(input: CreateDraftOrderInput): Promise<string> {
const order = this.factory.newOrder(
input.customerId,
input.currency,
input.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
unitPriceAmount: l.unitPrice,
})),
);
await this.orders.save(order);
return order.id;
}
}
8.4 NestJS の DI モジュール構成
// src/infrastructure/di/OrderModule.ts
import { Module } from "@nestjs/common";
import { Pool } from "pg";
import { Kafka } from "kafkajs";
import { PostgresOrderRepository } from "../persistence/PostgresOrderRepository";
import { KafkaDomainEventPublisher } from "../messaging/KafkaDomainEventPublisher";
import { OrderFactory } from "../../domain/order/OrderFactory";
import { PlaceOrderService } from "../../application/order/PlaceOrderService";
import { CreateDraftOrderService } from "../../application/order/CreateDraftOrderService";
import { CancelOrderService } from "../../application/order/CancelOrderService";
import { OrderController } from "../../interfaces/http/OrderController";
@Module({
controllers: [OrderController],
providers: [
{
provide: "PG_POOL",
useFactory: () => new Pool({ connectionString: process.env.DATABASE_URL }),
},
{
provide: "KAFKA",
useFactory: () => new Kafka({ brokers: (process.env.KAFKA_BROKERS ?? "").split(",") }),
},
{
provide: "OrderRepository",
useFactory: (pool: Pool) => new PostgresOrderRepository(pool),
inject: ["PG_POOL"],
},
{
provide: "DomainEventPublisher",
useFactory: (k: Kafka) => new KafkaDomainEventPublisher(k.producer()),
inject: ["KAFKA"],
},
{
provide: OrderFactory,
useFactory: (repo) => new OrderFactory(repo),
inject: ["OrderRepository"],
},
{
provide: CreateDraftOrderService,
useFactory: (f, r) => new CreateDraftOrderService(f, r),
inject: [OrderFactory, "OrderRepository"],
},
{
provide: PlaceOrderService,
useFactory: (r, p) => new PlaceOrderService(r, p),
inject: ["OrderRepository", "DomainEventPublisher"],
},
{
provide: CancelOrderService,
useFactory: (r, p) => new CancelOrderService(r, p),
inject: ["OrderRepository", "DomainEventPublisher"],
},
],
})
export class OrderModule {}
8.5 HTTP コントローラ
// src/interfaces/http/OrderController.ts
import { Body, Controller, Param, Post } from "@nestjs/common";
import { CreateDraftOrderService } from "../../application/order/CreateDraftOrderService";
import { PlaceOrderService } from "../../application/order/PlaceOrderService";
@Controller("orders")
export class OrderController {
constructor(
private readonly createDraft: CreateDraftOrderService,
private readonly place: PlaceOrderService,
) {}
@Post()
async create(@Body() body: any) {
const id = await this.createDraft.execute(body);
return { id };
}
@Post(":id/place")
async placeOrder(@Param("id") id: string) {
await this.place.execute(id);
return { ok: true };
}
}
8.6 ドメインイベントパブリッシャ実装
// src/infrastructure/messaging/KafkaDomainEventPublisher.ts
import { Producer } from "kafkajs";
import { DomainEvent } from "../../domain/shared/DomainEvent";
import { DomainEventPublisher } from "../../domain/shared/DomainEventPublisher";
export class KafkaDomainEventPublisher implements DomainEventPublisher {
constructor(private readonly producer: Producer) {}
async publishAll(events: DomainEvent[]): Promise<void> {
if (events.length === 0) return;
await this.producer.connect();
await this.producer.sendBatch({
topicMessages: events.map((e) => ({
topic: `domain.${e.name}`,
messages: [{ value: JSON.stringify(e) }],
})),
});
}
}
実運用では、Outbox テーブルへの書き込み + 別プロセスのリレーヤにすることで At-Least-Once を保証する。
9. テスト戦略
DDD はテスタビリティを劇的に高める。ドメイン層は依存を持たない純粋なロジックとして書かれているため、最も書きやすく速く実行できるテストになる。
9.1 ドメイン層:純粋ユニットテスト
DB もネットワークも不要。Jest や Vitest で数千件のテストが秒単位で回る。
// test/domain/order/Order.test.ts
import { Order } from "../../../src/domain/order/Order";
import { OrderItem } from "../../../src/domain/order/OrderItem";
import { Money } from "../../../src/domain/shared/Money";
describe("Order aggregate", () => {
it("rejects placement when empty", () => {
const o = new Order("o1", "c1", "JPY");
expect(() => o.place()).toThrow(/empty/);
});
it("calculates total correctly", () => {
const o = new Order("o1", "c1", "JPY");
o.addItem(new OrderItem("i1", "p1", 2, Money.of(100, "JPY")));
o.addItem(new OrderItem("i2", "p2", 1, Money.of(300, "JPY")));
expect(o.total().amount).toBe(500);
});
it("merges items with same productId", () => {
const o = new Order("o1", "c1", "JPY");
o.addItem(new OrderItem("i1", "p1", 1, Money.of(100, "JPY")));
o.addItem(new OrderItem("i2", "p1", 2, Money.of(100, "JPY")));
expect(o.items.length).toBe(1);
expect(o.items[0].quantity).toBe(3);
});
it("emits OrderPlaced event when placed", () => {
const o = new Order("o1", "c1", "JPY");
o.addItem(new OrderItem("i1", "p1", 1, Money.of(500, "JPY")));
o.place();
const events = o.pullEvents();
expect(events.map((e) => e.name)).toEqual(["OrderPlaced"]);
});
it("rejects cancellation after shipment", () => {
const o = Order.reconstitute({ id: "o1", customerId: "c1", currency: "JPY", status: "Shipped" });
expect(() => o.cancel()).toThrow(/shipped/);
});
});
9.2 アプリケーション層:インメモリ実装でのテスト
リポジトリやイベントパブリッシャのインメモリ偽実装を作ることで、I/O を切り離したまま統合的な振る舞いを検証できる。
// test/support/InMemoryOrderRepository.ts
import { Order } from "../../src/domain/order/Order";
import { OrderRepository } from "../../src/domain/order/OrderRepository";
import { randomUUID } from "crypto";
export class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
nextIdentity() { return randomUUID(); }
async findById(id: string) { return this.store.get(id) ?? null; }
async save(o: Order) { this.store.set(o.id, o); }
}
9.3 リポジトリ実装:Testcontainers
実際の SQL 方言や制約・トランザクション挙動を検証するため、Testcontainers で本番と同じ DB を起動する。
import { GenericContainer, StartedTestContainer } from "testcontainers";
import { Pool } from "pg";
let pg: StartedTestContainer; let pool: Pool;
beforeAll(async () => {
pg = await new GenericContainer("postgres:16")
.withEnvironment({ POSTGRES_PASSWORD: "test" })
.withExposedPorts(5432)
.start();
pool = new Pool({
host: pg.getHost(),
port: pg.getMappedPort(5432),
user: "postgres", password: "test", database: "postgres",
});
// migrate
});
afterAll(async () => { await pool.end(); await pg.stop(); });
9.4 契約テスト
Bounded Context をまたぐ連携には、Pact 等のコンシューマ駆動契約テストを使い、イベントスキーマやエンドポイントの後方互換性を保証する。
10. よくあるアンチパターン
10.1 貧血ドメインモデル(Anemic Domain Model)
Martin Fowler が 2003 年に名付けた最も古典的なアンチパターン。エンティティが getter/setter のみを持ち、ロジックがすべてサービス層に滲み出している状態。
// アンチパターン
class Order {
id: string; status: string; items: OrderItem[]; total: number;
}
class OrderService {
place(o: Order) {
if (o.items.length === 0) throw new Error();
if (o.status !== "Draft") throw new Error();
o.status = "Placed";
// ... 数百行
}
}
これは DDD のフリをしただけのトランザクションスクリプトである。ロジックを Order 自身のメソッドに戻すことで、不変条件と振る舞いが集約の境界の中に閉じ込められる。
10.2 肥大化したアプリケーションサービス
「アプリケーションサービスは調整のみ」という規律を破ると、ユースケース 1 つに数百行のロジックが書かれる。たとえば、PlaceOrderService の中で割引計算・在庫確認・決済呼び出し・通知送信を全部やってしまう。これらはドメインサービス・別の集約・別のユースケースに切り出すべきである。
10.3 漏れる ORM
Hibernate/TypeORM のレイジーロード、@Entity のフィールド可視性、N+1 問題などが、ドメインモデルの設計を歪めるケース。たとえば「集約ルートからすべての子をたどれるように」と巨大なオブジェクトグラフを抱え込んでしまう。
対策:
- ORM の
@Entityをそのままドメインのエンティティに使わない(永続化モデルとドメインモデルを分離する) - 集約は ID 参照を基本にし、必要な集約だけ別途リポジトリで取得する
- マッピングはリポジトリ実装の中に閉じ込める
10.4 God Aggregate
「とりあえず全部 1 つの集約にすれば整合性が守れる」という発想で、Customer 集約に Order[]、Order の中に Payment[] を詰め込んでしまう。結果、
- ロックの競合が頻発する
- メモリにロードする量が爆発する
- 変更影響範囲が読めなくなる
対策:集約は小さく、跨ぐ整合性は結果整合性で。
10.5 ID の漏洩
データベースの自動採番 ID をドメインに持ち込み、永続化前のオブジェクトが「ID なしの Order」になってしまう。対策は UUID v7 などをアプリ側で採番し、OrderRepository.nextIdentity() のような API で取得する。
10.6 Bounded Context を無視した「共通モデル」
「全社で使う Customer」を共通ライブラリに置き、すべてのサービスがそれを import する構造。ドメインの語彙の差異を消すことになり、ある変更が全社に波及する地獄を生む。Shared Kernel は本当に小さく、しかも変更に厳しい合意プロセスがある場合のみ使う。
10.7 過剰な戦術的パターン
CRUD で十分な領域に対して、Entity / VO / Aggregate / Repository / Domain Service / Domain Event / Specification / CQRS / ES を全部適用する。コアでない領域は素直な実装で良い。
11. DDD 導入のステップ
11.1 Event Storming ワークショップ
Alberto Brandolini が考案した、ドメインの全体像を発見するワークショップ手法。大きな壁にオレンジ色の付箋(ドメインイベント)を時系列に並べ、青(コマンド)、黄(アクター)、ピンク(ホットスポット)、紫(ポリシー)などを足していく。
進め方の概略:
- Big Picture:イベント(過去形)を時系列に並べる。3〜4 時間。
- Process Modeling:コマンド・アクター・ポリシーを足し、矛盾やホットスポットを洗い出す。
- Software Design:集約候補・ Bounded Context 境界を引く。
Event Storming はドメインエキスパートを巻き込んでこそ価値がある。開発者だけでやっても深い洞察は得られない。
11.2 Bounded Context の発見
Event Storming の結果から、
- 同じ単語が違う意味で使われている箇所
- 大きく異なるアクターが関与する領域
- データの所有が分かれる箇所
を境界として引く。チームの組織図と一致させることが Conway の法則上きわめて重要である(Inverse Conway Maneuver:望ましいアーキテクチャに合わせて組織を変える)。
11.3 Strangler Fig パターンによる段階移行
レガシーモノリスから DDD ベースの構造へ移行するときは、Martin Fowler の Strangler Fig を使う。
- 新しい Bounded Context を新サービスとして実装
- ルーティング層(API Gateway や Reverse Proxy)で、新機能のみ新サービスへ流す
- レガシー側の対応機能を段階的に縮小・削除
レガシーの DB と新サービスの DB の整合は、レガシーから outbox / CDC(Debezium)で流すイベントで取る。
11.4 段階的な戦術パターンの導入
- 第 1 段階:値オブジェクトの導入(Money、Email、UserId など)。低リスク・高効果。
- 第 2 段階:集約の境界を意識した状態遷移ロジックの内部化。
- 第 3 段階:リポジトリ抽象化と DI でインフラを切り離す。
- 第 4 段階:ドメインイベントの抽出と Outbox。
- 第 5 段階:必要に応じて CQRS、Event Sourcing。
すべてを最初から導入する必要はない。今のチームが理解できる範囲で始め、痛みに応じて深掘りしていくのが王道である。
12. ツールとエコシステム
12.1 設計ワークショップ系
- Event Storming:付箋とホワイトボードで実施。リモートでは Miro、Mural、EventStormer。
- Domain Storytelling:Stefan Hofer らが提唱。アクター・成果物・活動の絵で物語を語る。
- Example Mapping:BDD と接続。仕様の例を組織立てて発見する。
12.2 モデル可視化
- Context Mapper(contextmapper.org):Bounded Context と関係を DSL で記述し、PlantUML 図や Markdown を自動生成。
- Structurizr:C4 モデル準拠の図化ツール。Bounded Context をコンポーネント図に落とし込みやすい。
12.3 Java / JVM エコシステム
- jMolecules:
@Entity、@AggregateRoot、@ValueObjectなどのアノテーションで DDD 用語をコードに表現。リント・ドキュメント生成に有用。 - Spring Modulith:1 つのモジュラモノリスを複数の Bounded Context として分割し、イベント駆動の連携を支援。
- Axon Framework:CQRS + Event Sourcing の本格フレームワーク。
- Lagom(非推奨化が進む)、Quarkus + SmallRye Mutiny。
12.4 .NET エコシステム
- MassTransit:メッセージングの抽象(Saga 含む)。
- MediatR:In-process メッセージングで、コマンド/クエリ/ドメインイベントの分離に便利。
- NEventStore、Marten(PostgreSQL ベースのドキュメント DB + Event Store)。
12.5 Node.js / TypeScript エコシステム
- NestJS:モジュラなアーキテクチャ、DI、CQRS モジュール公式提供。
- typeorm-transactional や Prisma の
$transactionで UoW 風のトランザクション制御。 - EventStoreDB SDK:Event Sourcing 専用の追記型 DB。
- Effect-TS:純粋関数型のドメインモデリングと相性が良い。
12.6 イベントインフラ
- Apache Kafka:パーティション順序保証、リプレイ可能な永続ログ。
- EventStoreDB:イベントソーシング専用 DB。
- NATS JetStream、RabbitMQ Streams、AWS Kinesis、GCP Pub/Sub。
- Debezium:DB の変更データキャプチャ(CDC)で Outbox を実装。
12.7 観察可能性(Observability)
DDD +イベント駆動システムは、Saga 全体をまたぐ trace が運用上必須となる。OpenTelemetry の trace context をイベントヘッダに伝播させ、Jaeger や Tempo で可視化する。
13. 他手法との比較
13.1 Transaction Script
Martin Fowler の『Patterns of Enterprise Application Architecture』で紹介された、最もシンプルなパターン。ユースケースごとに 1 つの手続きを書き、SQL を直接呼ぶ。
function placeOrder(orderId: string, db: Db) {
const order = db.query("SELECT ... WHERE id=?", [orderId]);
if (order.status !== "Draft") throw new Error();
db.execute("UPDATE orders SET status='Placed' WHERE id=?", [orderId]);
}
長所:理解が容易、立ち上げが速い。 短所:ロジックが手続きに散らばる、再利用しにくい、複雑になると破綻する。
ドメインの複雑さが低いシステム には Transaction Script で十分である。DDD はオーバースペック。
13.2 Active Record
Rails の ActiveRecord に代表される、1 テーブル 1 クラスで永続化と振る舞いを 1 オブジェクトに同居させるパターン。
長所:CRUD アプリでは劇的に短く書ける。 短所:DB スキーマがドメインモデルを支配する。集約の概念がなく、不変条件を守りにくい。複雑な業務ルールには向かない。
13.3 Anemic Domain Model
すでに述べた通り、データホルダだけを並べてサービス層にロジックを押し込む。「DDD らしい」と誤解されやすいが、実態は Transaction Script + 余分なクラス階層。
13.4 比較表
| 観点 | Transaction Script | Active Record | Anemic Domain | DDD(戦術) |
|---|---|---|---|---|
| ドメインの複雑さ耐性 | 低 | 中 | 低 | 高 |
| 開発初速 | 高 | 高 | 中 | 中 |
| テスト容易性 | 中 | 低 | 中 | 高 |
| 不変条件の表現 | 弱 | 弱 | 弱 | 強 |
| 学習コスト | 低 | 低 | 中 | 高 |
| マイクロサービスとの親和性 | 低 | 低 | 中 | 高 |
選定指針:
- ドメインが単純で、寿命の短いプロダクト → Transaction Script / Active Record
- 中規模で安定した業務、CRUD 主体 → Active Record + 部分的に値オブジェクト
- 中〜大規模で、業務ルールが競争優位の源泉 → DDD(戦略 + 戦術両輪)
14. まとめ
14.1 DDD のエッセンス再考
長く詳細に見てきたが、DDD の核は次の 3 行に集約される。
- ドメインを深く理解せよ(ユビキタス言語、Event Storming、ドメインエキスパートとの対話)
- 境界を引け(Bounded Context、Context Map、サブドメインの分類)
- ドメインを中心にコードを書け(戦術的パターン、依存関係逆転、ヘキサゴナル)
戦術的パターン(Entity / VO / Aggregate / Repository / ...)は手段に過ぎない。戦略的設計(境界とコアの見極め)こそが DDD の最大の貢献である。
14.2 失敗を避けるための心得
- 小さく始めよ。値オブジェクトと集約境界の規律から導入し、痛みに応じて段階的に深掘る。
- ドメインエキスパートを巻き込め。彼らなしの DDD は形だけになる。
- コアでない領域に過剰投資するな。Generic / Supporting には素朴な実装で良い。
- イベントは過去形で命名し、Outbox で確実に配送せよ。
- 集約は小さく、トランザクションは集約 1 つ。跨ぐ整合性は結果整合性で。
- テストは内側ほど多く・速く、外側に行くほど少なく・遅く(テストピラミッド)。
- 語彙が変わったらコードも即座に変える規律を持つ。
- DDD は組織と切り離せない。チーム境界・所有 DB・デプロイパイプラインも DDD の一部である。
14.3 これから学ぶ人へ
推奨される学習順序の一例:
- Eric Evans『Domain-Driven Design』(Blue Book)の戦略的設計部分(第 IV 部)から読む
- Vaughn Vernon『Implementing Domain-Driven Design』(Red Book)で実装イメージを掴む
- 自分のチームで Event Storming を 1 度やってみる
- 既存コードベースのうち 1 集約だけリファクタしてみる
- Vlad Khononov『Learning Domain-Driven Design』でモダンな解釈をアップデート
DDD は「正しい絵を描く方法論」ではなく、**「複雑さに対峙する継続的な活動」**である。完璧なモデルは存在せず、よりよいモデルを発見し続ける営みこそが本質である。本記事がその旅の地図になれば幸いである。