はじめに
「DDDってなんとなく知ってるけど、実際に何をすれば DDD なの?」
「Entity と Value Object の違いって結局なに?」
「Repository を作ったらそれは DDD?」
…という疑問を持っている方は多いのではないでしょうか。本記事では、Eric Evans の『Domain-Driven Design』(通称: 青本)、Vaughn Vernon の『Implementing Domain-Driven Design』(通称: 赤本)を踏まえつつ、実務で使える形 で DDD のエッセンスを整理してみます。
※ 主要な用語の説明は、Eric Evans の DDD Reference と Vaughn Vernon の公式概要で公開されている説明に寄せてあります。
DDD とは何か
ドメイン駆動設計 (Domain-Driven Design) は、複雑なビジネスロジックを持つソフトウェアを開発する際に、
ソフトウェアの中心に「ドメイン(業務領域)」を据え、ドメインの言葉でコードを書こう
という思想に基づく設計手法です。
ポイントは「フレームワーク」でも「アーキテクチャ」でもなく、思想と技法の集合体 であるということ。だから「DDD のライブラリを入れれば DDD」にはなりません。
DDD は大きく 2 階層に分かれる
DDD は大きく次の 2 つに分けられます。
| 分類 | 別名 | 何をするか |
|---|---|---|
| 戦略的設計 | Strategic Design | ドメインを正しく分割し、境界を引く |
| 戦術的設計 | Tactical Design | コードレベルでドメインを表現する |
「DDD = Entity と Value Object を作ること」と思われがちですが、それは戦術的設計の話。本当に重要なのはむしろ 戦略的設計 の方です。
戦略的設計
ユビキタス言語 (Ubiquitous Language)
DDD の出発点であり、最重要概念です。
ドメインエキスパート(業務に詳しい人)、開発者、ステークホルダーが 同じ言葉を使う
たとえば EC サイトで「注文」と言ったとき、
- マーケティング部「カートに入れた瞬間が注文」
- 経理「支払いが完了した瞬間が注文」
- 物流「出荷指示が出た瞬間が注文」
…と人によって意味が違うのはよくある話。これをそのままコードに落とすと、Order クラスが何を意味するのか誰にも分からなくなります。
モデルを言葉の背骨にし、会話とコードの両方でその言葉を使い続ける。これが DDD の基本です。
境界づけられたコンテキスト (Bounded Context)
ユビキタス言語は、組織全体で 1 つに統一する必要はありません(むしろ無理)。
そこで「この範囲ではこの言葉はこの意味」と境界を引きます。これが 境界づけられたコンテキスト です。境界は会話上の約束だけでなく、チーム編成、アプリケーション内での適用範囲、コードベースやデータストアの分離にも表れます。
flowchart LR
subgraph Sales["販売コンテキスト"]
S["Order =<br/>カート確定後の購入意思"]
end
subgraph Logistics["物流コンテキスト"]
L["Order =<br/>出荷指示が出ている荷物"]
end
N["同じ Order でも意味が違ってよい"]
S -.-> N
L -.-> N
classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
class S,L context
class N noteこの境界の地図が コンテキストマップ です。マイクロサービスの境界を決めるときの強力な指針にもなります。
要件からドメインを抽出するには
「で、結局どうやってドメインを見つけるの?」という疑問は誰もが通る道です。ここでは現場で実際に役立つアプローチをいくつか紹介します。
① ヒアリングは「動詞」と「名詞」を拾うつもりで
顧客や業務担当者と話すときに意識したいのは、出てくる 名詞 = モデル候補 と 動詞 = ふるまい候補 を分けて記録することです。
「営業担当者 が 見積書 を作って 顧客 に 送付 し、承認 されたら 受注 に変える」
この一文だけで、
- 名詞: 営業担当者、見積書、顧客、受注 → Entity / Value Object 候補
- 動詞: 作る、送付する、承認する、受注に変える → ふるまい / ドメインイベント候補
が拾えます。「業務担当者が無意識に使っている言葉」こそがユビキタス言語の原石 です。
② イベントストーミングを使う
複雑なドメインの場合、ホワイトボード(または Miro / FigJam)に 付箋でイベントを並べる イベントストーミングが非常に有効です。
flowchart LR
E1["🟧 見積依頼を受けた"] --> E2["🟧 見積を作成した"] --> E3["🟧 見積を送付した"]
E3 --> C2["🟦 コマンド<br/>見積を承認する"] --> E4["🟧 見積が承認された"] --> E5["🟧 受注した"]
E3 --> C1["🟦 コマンド<br/>見積をキャンセルする"] --> E6["🟧 キャンセルされた"]
A1["🟪 集約<br/>見積書"] -.イベントを引き起こす本体.-> E2
R1["🟨 ルール<br/>承認済みの見積だけが受注に変換できる"] -.制約.-> E5
classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
class E1,E2,E3,E4,E5,E6 event
class C1,C2 command
class A1 aggregate
class R1 rule- 🟧 オレンジ付箋: ドメインイベント(過去形で書く)
- 🟦 青付箋: コマンド(誰かのアクション)
- 🟪 紫付箋: 集約(イベントを引き起こす本体)。例:
見積書 - 🟨 黄付箋: ビジネスルール / 制約。例: 「承認済みの見積だけが受注に変換できる」
たとえば 「見積を承認する」 は誰かが実行する指示なのでコマンドです。一方で 「見積が承認された」 はすでに起きた事実なのでドメインイベントです。
業務担当者と一緒にやることで、暗黙知が一気に可視化 されます。「あ、ここでこの判断が入るんですか?」「実はこの場合は例外で…」といった会話が自然と生まれるのが最大の価値です。
③ 「例外パターン」を必ず聞く
要件ヒアリングで一番情報量が多いのは 例外フロー です。
| 質問 | 引き出せる情報 |
|---|---|
| 「これができないケースはどんなとき?」 | 不変条件・ビジネスルール |
| 「過去に揉めた事例は?」 | 隠れた制約 |
| 「手作業で運用回避していることは?」 | 未実装の業務ルール |
| 「この業務、5 年前と何が変わった?」 | 変化しやすい部分 / 安定部分 |
ハッピーパスばかり聞いていてもドメインの輪郭は見えてきません。痛みのあるところにドメインの本質が潜んでいます。
④ コア・支援・汎用の 3 種類に分類する
抽出したドメインは、すべて同じ重さで実装する必要はありません。
| 分類 | 説明 | 戦略 |
|---|---|---|
| コアドメイン | ビジネスの競争優位の源泉 | 自前で丁寧に DDD で作る |
| 支援サブドメイン | コアを支える固有業務 | 軽量に自前実装 or 外注 |
| 汎用サブドメイン | どの会社にも共通(認証、決済等) | SaaS / OSS で済ませる |
EC サイトを例にすると、
- コア: 商品レコメンド、価格戦略、在庫引当
- 支援: 商品カタログ管理、配送調整
- 汎用: 認証、決済、メール送信
「全部頑張ろう」とすると確実に破綻するので、力の入れどころを明確にする ことが境界設計の第一歩です。
⑤ コンテキスト境界を引く判断基準
境界をどこに引くかは経験が要りますが、次のサインを目印にすると判断しやすくなります。
- 🚩 同じ言葉の意味が変わる(冒頭の Order の例)
- 🚩 担当する組織・部署が変わる(コンウェイの法則)
- 🚩 ライフサイクルが違う(注文と顧客は寿命が違う)
- 🚩 変更頻度が違う(価格戦略は頻繁に変わるが、住所管理はそうでもない)
- 🚩 求められる整合性レベルが違う(在庫は強整合、レコメンドは結果整合で OK)
これらの「ズレ」を感じたら、そこが境界の候補です。
戦術的設計
ここからがコードの話。
Value Object (値オブジェクト)
「値そのもの」を表すオブジェクトで、次の特徴を持ちます。
- 不変 (immutable)
- 同値性で比較される(IDではなく中身で比較)
- 副作用を持たない
// ❌ ただのstring
const email: string = "user@example.com";
// ✅ Value Object
class Email {
constructor(private readonly value: string) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error("無効なメールアドレスです");
}
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
「メールアドレスとして有効である」という制約を 型に閉じ込める のがポイント。バリデーションがあちこちに散らばらなくなります。
Entity (エンティティ)
識別子 (ID) によって同一性が決まる オブジェクトです。
class User {
constructor(
public readonly id: UserId,
private name: UserName,
private email: Email,
) {}
changeEmail(newEmail: Email): void {
this.email = newEmail;
}
equals(other: User): boolean {
return this.id.equals(other.id);
}
}
名前が変わっても、メールが変わっても、id が同じなら同じ User。これが Entity の本質です。
集約 (Aggregate)
複数の Entity / Value Object をひとまとまりにした 整合性の境界 です。
集約の入口となるエンティティを 集約ルート (Aggregate Root) と呼び、外部からは必ず集約ルート経由でアクセスします。
// 集約ルート
class Order {
private items: OrderItem[] = [];
constructor(
public readonly id: OrderId,
private readonly userId: UserId,
) {}
// 集約内部の整合性は集約ルートが守る
addItem(product: Product, quantity: number): void {
if (this.items.length >= 100) {
throw new Error("1注文あたりのアイテム数上限を超えています");
}
this.items.push(new OrderItem(product.id, quantity, product.price));
}
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
}
集約のサイズはできるだけ小さく が鉄則。大きくしすぎると同時更新の競合や読み込みコストで苦しみます。
Repository (リポジトリ)
集約の永続化を担う抽象です。ドメイン層がインフラに依存しないため に挟みます。
// ドメイン層: インターフェースだけを定義
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// インフラ層: 実装は外側に置く
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> { /* ... */ }
async save(order: Order): Promise<void> { /* ... */ }
}
ここで重要なのは、Repository は 集約ルートに対するグローバルなアクセスを提供し、呼び出し側からはコレクションのように扱える抽象 だということ。「DB に保存する」ではなく「集約を保管し、取り出すための窓口」と捉えましょう。
Domain Service (ドメインサービス)
ドメインにおける重要なプロセスや変換で、Entity や Value Object の自然な責務に落とし込むのが不自然なものの置き場所です。
// 「メールアドレスの重複チェック」は User 単体では判断できない
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
Entity か Service か、迷ったときの判断フロー
実装中に最も悩むのが「このロジック、Entity に書くべきか Service に書くべきか」問題です。次のフローで判断するとほとんどのケースを捌けます。
flowchart TB
Start["そのロジックは…"] --> Q1{"単一のオブジェクトの状態を変えるだけ?"}
Q1 -- YES --> E["Entity のメソッド"]
Q1 -- NO --> Q2{"複数の集約をまたいで判断する?"}
Q2 -- YES --> D["Domain Service"]
Q2 -- NO --> Q3{"リポジトリや外部サービスを必要とする?"}
Q3 -- YES --> D
Q3 -- NO --> Q4{"名詞ではなく動詞で表現するのが自然?"}
Q4 -- YES --> D
Q4 -- NO --> V["Entity / Value Object"]
classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
class Q1,Q2,Q3,Q4 question
class E,D,V answer✅ Entity に置くべきもの
自分自身の状態と不変条件を守るロジック は Entity の責務です。
class Order {
private status: OrderStatus;
private items: OrderItem[];
// ✅ 自分の状態遷移は自分で守る
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error("発送済みの注文はキャンセルできません");
}
this.status = OrderStatus.CANCELLED;
}
// ✅ 自分のデータから計算するだけのロジック
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
// ✅ 自分の不変条件のチェック
addItem(item: OrderItem): void {
if (this.items.length >= 100) throw new Error("上限を超えています");
this.items.push(item);
}
}
判断基準: 「Order さん、キャンセルできる?」と擬人化して聞いて自然なら Entity の責務。
✅ Domain Service に置くべきもの
1 つの Entity だけでは判断できない、または外部とのやりとりが必要なロジック は Domain Service です。
// ✅ 2つの集約をまたぐ「送金」ロジック
class TransferService {
transfer(from: Account, to: Account, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
// どちらの口座にも属さない「送金」という概念
}
}
// ✅ リポジトリが必要な「重複チェック」
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
判断基準: 「User さん、自分のメアドが他に登録されているか分かる?」と聞いたとき "知らんがな" となるなら Service。
❌ アンチパターン: 何でも Service に置く
// ❌ こうなってはいけない
class OrderService {
cancel(order: Order): void {
if (order.status === "SHIPPED") throw new Error(/* ... */);
order.status = "CANCELLED"; // setter で書き換え
}
totalPrice(order: Order): Money { /* items を回して合計 */ }
addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
status: string;
items: OrderItem[];
// getter / setter のみ ← ドメインモデル貧血症
}
これは典型的な トランザクションスクリプト で、Entity が単なるデータ構造になっています。「Order を主語にして書ける処理は、Order に書く」が原則です。
まとめると
| 観点 | Entity に置く | Domain Service に置く |
|---|---|---|
| 主語 | そのオブジェクト自身 | 動詞・コト |
| 状態 | 自分の状態を変更 | 状態は持たない (stateless) |
| 依存 | Repository に依存しない | Repository / 外部に依存可 |
| 範囲 | 単一の集約内 | 複数集約をまたぐ |
| 例 | order.cancel() |
transferService.transfer(a, b, money) |
Domain Event (ドメインイベント)
「ドメインで起きた重要な出来事」を表すオブジェクト。マイクロサービスや非同期処理との相性がよく、近年特に注目されています。
class OrderPlaced {
constructor(
public readonly orderId: OrderId,
public readonly userId: UserId,
public readonly occurredAt: Date,
) {}
}
「注文された → メール送信」「注文された → ポイント付与」のように、副作用を疎結合に分離できます。
レイヤードアーキテクチャ
DDD ではよく次のような層構造で実装します。
flowchart TB
P["Presentation 層<br/>UI / API"]
A["Application 層<br/>ユースケース"]
D["Domain 層<br/>モデル・ビジネスルール<br/>ここが主役"]
I["Infrastructure 層<br/>DB / 外部 API"]
P --> A --> D
I --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class P,A,I layer
class D domain依存の向きは 常に内向き。Domain 層は他のどの層にも依存しません。これを徹底するために オニオンアーキテクチャ や ヘキサゴナルアーキテクチャ(ポート&アダプタ) が用いられます。
各層の責務を明確にする
実際に開発するとき、最も事故りやすいのが 「ロジックがあちこちの層に染み出す問題」 です。各層の責務を一文で押さえておきましょう。
| 層 | 責務 | やってはいけないこと |
|---|---|---|
| Presentation | リクエストを受け、結果を表示 | ビジネスルールを書く |
| Application | ユースケースの調整役 | ビジネスルールを書く |
| Domain | ビジネスルールそのもの | フレームワーク・DB に依存する |
| Infrastructure | 永続化・外部通信の実装 | ビジネス判断をする |
ポイントは 「ビジネスルールは Domain 層にしか書かない」 という強い制約です。これを守れば、フレームワークが変わってもビジネスロジックは生き残ります。
Presentation 層の役割
ユーザー(または別システム)からの入力を受け取り、Application 層に渡し、結果を返すだけ。できるだけ薄く が鉄則です。
// ✅ 良い Controller: 受け流すだけ
@Controller("orders")
class OrderController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
@Post()
async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
// 1. リクエストの形を整える(DTO への変換)
const command = new PlaceOrderCommand(
body.userId,
body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
);
// 2. ユースケースに委譲
const result = await this.placeOrder.execute(command);
// 3. レスポンスの形に整えて返す
return OrderResponse.from(result);
}
}
Presentation 層がやるべきこと:
- リクエストのパース・バリデーション(形式的なもの:必須項目、型、文字数など)
- 認証・認可の入口処理
- レスポンス形式への変換
- HTTP ステータスコードの選択
逆に やってはいけない のは「在庫がなければ 400 を返す」のような 業務判断。これは Domain 層の仕事です。Presentation はあくまで「Domain が出した結果を HTTP の言葉に翻訳する」だけ。
Application 層の役割
DDD で最も誤解されがちな層です。Application Service は ユースケースの調整役 であり、ビジネスロジックは持ちません。
// ✅ 良い Application Service: 流れをコーディネートするだけ
class PlaceOrderUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly productRepository: ProductRepository,
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService, // Domain Service
private readonly transactionManager: TransactionManager,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderResult> {
return this.transactionManager.run(async () => {
// 1. 必要な集約を取得
const user = await this.userRepository.findById(command.userId);
if (!user) throw new UserNotFoundError(command.userId);
const products = await this.productRepository.findByIds(
command.items.map(i => i.productId),
);
// 2. ドメインに処理を委ねる(ビジネスルールは Domain 内)
const order = Order.place(user, products, command.items);
// 3. ドメインサービスを呼び出す(複数集約をまたぐ判断)
await this.inventoryService.reserve(order);
// 4. 永続化
await this.orderRepository.save(order);
// 5. ドメインイベントの発行
await this.eventPublisher.publishAll(order.pullEvents());
return OrderResult.from(order);
});
}
}
Application Service の責務はこの 5 つです。
- 集約の取得(Repository を呼び出す)
- ドメインへの委譲(Entity / Domain Service にルールを実行させる)
- トランザクション境界の管理
- 永続化の指示(Repository への保存)
- ドメインイベントの発行
逆に、Application Service に 書いてはいけないコード の典型例:
// ❌ アンチパターン: Application 層にビジネスルールが漏れている
class PlaceOrderUseCase {
async execute(command: PlaceOrderCommand) {
// ❌ 在庫チェックをここで判定 → ドメイン知識の流出
if (product.stock < command.quantity) {
throw new Error("在庫不足");
}
// ❌ 合計金額の計算をここで行う → Order の責務
const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
// ❌ 状態判定をここで行う → Order の責務
if (user.status === "BLOCKED") throw new Error("利用停止中");
await this.orderRepository.save({ /* ... */ });
}
}
「業務担当者にこのコードを見せて、業務ルールが理解できるか?」が判定の目安。if 文で業務判断していたら、それは Domain 層に引っ越すべきサインです。
各層を貫く 1 リクエストの流れ
実際の開発で「ユーザーが注文ボタンを押した」ときの流れを追いかけてみましょう。
sequenceDiagram
participant Browser
participant Presentation as Presentation / OrderController
participant Application as Application / PlaceOrderUseCase
participant Domain as Domain / Order.place(...)
participant Infrastructure as Infrastructure / Repository 実装
Browser->>Presentation: POST /orders
Presentation->>Application: PlaceOrderCommand を渡す
Note over Application: トランザクション開始
Application->>Infrastructure: userRepository.findById()
Application->>Infrastructure: productRepository.findByIds()
Infrastructure-->>Application: User / Product 集約
Application->>Domain: Order.place(user, products, items)
activate Domain
Domain->>Domain: ビジネスルール検証
Domain->>Domain: 不変条件チェック
Domain->>Domain: OrderPlaced イベント生成
Domain-->>Application: Order 集約と OrderPlaced
deactivate Domain
Application->>Domain: inventoryService.reserve(order)
Domain-->>Application: 在庫引当結果
Application->>Infrastructure: orderRepository.save(order)
Application->>Infrastructure: eventPublisher.publishAll(...)
Infrastructure-->>Application: 永続化完了
Note over Application: トランザクションコミット
Application-->>Presentation: OrderResult
Presentation-->>Browser: 201 Createdこの流れを見ると分かるように、ビジネス判断が発生するのは Domain 層の中だけ。他の層は Domain がうまく仕事できるよう「準備」と「後片付け」をしているにすぎません。
DTO とドメインオブジェクトの境界
層をまたぐとき、ドメインオブジェクトをそのまま外に出さない のが定石です。
// Domain 層
class Order { /* ビジネスロジックを持つ集約 */ }
// Application 層: 入力 / 出力の DTO
class PlaceOrderCommand { /* リクエスト用 */ }
class OrderResult { /* レスポンス用 */ }
// Presentation 層: HTTP 用の型
class PlaceOrderRequest { /* JSON のスキーマ */ }
class OrderResponse { /* レスポンス JSON */ }
「面倒では?」と思うかもしれませんが、これがあるおかげで:
- API の形を変えても Domain が無傷
- JSON シリアライズの都合がドメインを汚さない
- GraphQL / gRPC への切り替えも局所的な変更で済む
特に Domain Object には @Column のような ORM のデコレータや @Expose のようなシリアライズ用のアノテーションを 絶対に付けない こと。これが付いた瞬間、Domain がフレームワークに依存します。
実装するときの推奨順序
最後に、新規機能を DDD で実装するときの おすすめの作業順序 です。
- ユースケースを 1 文で書く:「ユーザーが商品を注文する」
- 登場する用語を抽出:ユーザー / 商品 / 注文 / 在庫
- 集約とその境界を決める:Order 集約に何を含めるか
- Domain 層から書く:Entity・Value Object・Domain Service
- Domain 層のテストを書く(DB なしで動くはず!)
- Application Service を書く:Repository は interface のまま使う
- Infrastructure 層を実装:Repository の本物の実装、ORM 設定
- Presentation 層を実装:Controller、リクエスト・レスポンス型
- E2E テスト
ポイントは 内側から書く こと。「DB のテーブル設計から始める」のは DDD の精神に反します。ドメインモデルが先、テーブルは後。これだけは譲ってはいけない原則です。
フォルダ構造のベストプラクティス
DDD のフォルダ構成で最初にぶつかる問いがこれです。
レイヤー別 に切るか、ドメイン別(機能別) に切るか?
結論から言うと 「ドメイン別 → レイヤー別」の順で切るのが王道 です。
❌ レイヤー優先(アンチパターン寄り)
src/
├── controllers/
│ ├── user.controller.ts
│ ├── order.controller.ts
│ └── product.controller.ts
├── services/
│ ├── user.service.ts
│ ├── order.service.ts
│ └── product.service.ts
├── repositories/
│ ├── user.repository.ts
│ ├── order.repository.ts
│ └── product.repository.ts
└── entities/
├── user.entity.ts
├── order.entity.ts
└── product.entity.ts
最初は分かりやすいのですが、
- 1 つのユースケースを変更するときに 4 つのフォルダを行き来する
- ドメインの境界が見えない(全部フラットに見える)
- マイクロサービス化するときに切り出しにくい
…と、規模が大きくなると一気に苦しくなります。
✅ ドメイン優先(推奨)
src/
├── contexts/ ← Bounded Context 単位
│ ├── ordering/ ← 受注コンテキスト
│ │ ├── domain/
│ │ │ ├── order.ts (Entity / Aggregate Root)
│ │ │ ├── order-item.ts
│ │ │ ├── order-id.ts (Value Object)
│ │ │ ├── order-status.ts
│ │ │ ├── order.repository.ts(interface のみ)
│ │ │ └── events/
│ │ │ └── order-placed.ts
│ │ ├── application/
│ │ │ ├── place-order.usecase.ts
│ │ │ └── cancel-order.usecase.ts
│ │ ├── infrastructure/
│ │ │ ├── postgres-order.repository.ts
│ │ │ └── stripe-payment.gateway.ts
│ │ └── presentation/
│ │ ├── order.controller.ts
│ │ └── dto/
│ │ ├── place-order.request.ts
│ │ └── order.response.ts
│ ├── catalog/ ← 商品カタログコンテキスト
│ │ └── (同じ構造)
│ └── shipping/ ← 配送コンテキスト
│ └── (同じ構造)
├── shared-kernel/ ← 全コンテキスト共通の Value Object
│ ├── money.ts
│ ├── email.ts
│ └── user-id.ts
└── shared/ ← 横断的な技術基盤
├── logger.ts
├── transaction-manager.ts
└── event-bus.ts
このレイアウトのメリットは大きく 3 つ。
- コンテキストの境界が物理的に見える —
ordering/の中だけで完結する設計が自然と生まれる - マイクロサービス化が容易 —
contexts/ordering/をそのまま別リポジトリに切り出せる - コードレビューの観点が絞れる — 「この変更は ordering の中だけか?」が一目で分かる
ドメイン層の中の整理
domain/ 配下が肥大化してきたら、集約単位でフォルダを切る のがおすすめです。
ordering/domain/
├── order/
│ ├── order.ts (集約ルート)
│ ├── order-item.ts
│ ├── order-id.ts
│ ├── order-status.ts
│ ├── order.repository.ts
│ └── events/
│ ├── order-placed.ts
│ └── order-cancelled.ts
├── customer/
│ ├── customer.ts
│ └── customer.repository.ts
└── services/
└── pricing.service.ts (Domain Service)
集約が「自分の世界」を持つように整理されていると、依存関係も追いやすくなります。
モノレポでの構成
複数のサービスを 1 リポジトリで管理する場合は、こうなります。
monorepo/
├── apps/
│ ├── web/ (Next.js のフロントエンド)
│ ├── api/ (バックエンド API)
│ └── admin/ (管理画面)
├── packages/
│ ├── ordering-context/ ← パッケージ化された Bounded Context
│ ├── catalog-context/
│ ├── shipping-context/
│ └── shared-kernel/
└── package.json
各 Bounded Context が 独立した npm パッケージ になっているのがポイント。apps/api がそれを import して使うだけ、という構造になり、 コンテキスト間の依存をパッケージレベルで強制 できます。
命名規則のヒント
ファイル名・クラス名にも一貫性が欲しいところ。経験上うまくいくルールを挙げると:
| 対象 | 命名例 |
|---|---|
| Entity / Aggregate Root | Order(名詞、単数形) |
| Value Object | Email、Money、OrderId |
| Repository (interface) | OrderRepository(末尾 Repository) |
| Repository (実装) | PostgresOrderRepository(技術名 + Repository) |
| Domain Service | PricingService、TransferService |
| Application Service | PlaceOrderUseCase、CancelOrderUseCase |
| Domain Event | OrderPlaced、OrderCancelled(過去形) |
| Command (DTO) | PlaceOrderCommand |
特に Domain Event を過去形にする のは重要。「これは "起きたこと" であり "これからやること" ではない」と明示できます。
UI を持つシステムでのレイヤー設計
ここまではバックエンド寄りの話が中心でしたが、 UI(Web フロントエンド) が加わるとレイヤーの考え方はどう変わるのでしょうか。
バックエンドの 4 層をフロントにそのまま持ち込むと…
結論から言うと、 無理に持ち込まない方が良い です。フロントには独自の関心事があり、サーバーと同じレイヤー構造を強制すると窮屈になります。
[フロント特有の関心]
- ルーティング
- 画面ステート(ローディング・エラー・選択中等)
- フォームバリデーション(UX 向け)
- アニメーション
- リアクティブな再描画
これらをすべて Domain 層に押し込むと、Domain がフレームワーク(React や Vue)に依存してしまい、本末転倒になります。
フロントエンドのレイヤーモデル
実用的なのは、こんな分け方です。
flowchart TB
R["Pages / Routes 層<br/>Next.js の app/, pages/ など"]
C["Components 層<br/>UI コンポーネント / 見た目の責務"]
V["ViewModel / Hooks 層<br/>画面状態とユースケース呼出"]
A["Application 層<br/>ユースケース / API クライアント"]
D["Domain 層<br/>フロント側の最小限のドメインモデル"]
R --> C --> V --> A --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class R,C,V,A layer
class D domain各層の責務は次のようになります。
| 層 | 責務 | 例 |
|---|---|---|
| Pages / Routes | URL とページの対応付け | /orders/new ページ |
| Components | 見た目とインタラクション | <OrderForm />、<Button /> |
| ViewModel / Hooks | 画面の状態管理、ユースケースの起動 | useOrderForm() |
| Application | API 呼び出し、データの整形 | placeOrderUseCase() |
| Domain | フロント側で必要なドメインルール | Order.canCancel() の判定 |
React + TypeScript での実装例
// Domain 層: バックエンドと同じドメインルールを薄く持つ
class Order {
constructor(
public readonly id: string,
public readonly status: OrderStatus,
public readonly items: OrderItem[],
) {}
// UI で「キャンセルボタン押せるか?」の判定はここに集約
canCancel(): boolean {
return this.status === "PENDING" || this.status === "CONFIRMED";
}
totalPrice(): number {
return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
}
}
// Application 層: API を叩くユースケース
class PlaceOrderUseCase {
constructor(private readonly api: OrderApiClient) {}
async execute(items: CartItem[]): Promise<Order> {
const response = await this.api.placeOrder({ items });
return Order.fromResponse(response);
}
}
// ViewModel / Hooks 層: 画面状態を管理
function useOrderForm() {
const [items, setItems] = useState<CartItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);
const submit = async () => {
setIsSubmitting(true);
try {
await placeOrder.execute(items);
} catch (e) {
setError(formatError(e));
} finally {
setIsSubmitting(false);
}
};
return { items, setItems, isSubmitting, error, submit };
}
// Components 層: 見た目だけに集中
function OrderForm() {
const { items, isSubmitting, error, submit } = useOrderForm();
return (
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
{/* JSX */}
</form>
);
}
ポイントは Component が「見た目」だけに集中できている こと。状態管理は Hook に、ビジネスルール(canCancel() 等)は Domain に、API 呼び出しは Application に分かれています。
フロント側の Domain 層、どこまで持つ?
これは プロジェクトの性質 で決まります。
| プロジェクトの性質 | フロントの Domain 層 |
|---|---|
| 単純な CRUD 画面ばかり | ほぼ不要、API レスポンスを直接使う |
| 複雑な計算・判定が UI 側で必要 | 持つ(価格再計算、選択可否判定など) |
| オフライン対応 / モバイル | しっかり持つ(後述) |
「サーバーと同じドメインモデルをコピーする必要はない」というのも重要なポイント。フロント側は ユーザー操作に関係するルールだけ に絞り込みましょう。
BFF (Backend for Frontend) という選択肢
「フロントとバックエンドのデータ形式が合わない」「複数 API を 1 画面で組み合わせたい」という場合は、BFF を挟む のも有効な手です。
flowchart TB
Browser["Browser"]
BFF["BFF<br/>Next.js API Routes / Hono など<br/>画面に最適化したエンドポイント"]
Ordering["受注サービス"]
Catalog["カタログサービス"]
Inventory["在庫サービス"]
Response["画面向けに整形したレスポンス"]
Browser --> BFF
BFF --> Ordering
BFF --> Catalog
BFF --> Inventory
Ordering --> BFF
Catalog --> BFF
Inventory --> BFF
BFF --> Response --> Browser
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
class Browser,BFF layer
class Ordering,Catalog,Inventory service
class Response outputBFF は ドメインを持たない、画面のためだけの薄い層。集約をまたいで一括取得したり、複数コンテキストの結果を 1 つの JSON に整形して返したりします。CQRS の Query 側として機能させるのも自然です。
スマホアプリに DDD は適用できるか
「DDD ってサーバー側の話でしょ?」
と思われがちですが、スマホアプリにも十分適用できます。 むしろオフライン対応や同期処理が絡むぶん、ドメインモデルの恩恵が大きいケース です。
スマホアプリ特有の事情
| 事情 | 影響 |
|---|---|
| オフライン対応 | ローカル DB が必要 → Repository が活きる |
| プッシュ通知 / バックグラウンド処理 | ドメインイベントとの相性が良い |
| 端末のセンサー / カメラ | Infrastructure 層として扱う |
| アプリのライフサイクル | 状態の永続化が複雑 |
| バージョン互換 | 古い端末との後方互換が必要 |
これらを「Activity / Fragment / ViewController に直書き」してしまうと、OS のアップデートで全壊する という事故が起きます。
ネイティブ実装での層構成例(Swift / Kotlin)
App/
├── Domain/
│ ├── Entities/
│ │ ├── Recipe.swift
│ │ └── Ingredient.swift
│ ├── ValueObjects/
│ │ └── CookingTime.swift
│ ├── Repositories/
│ │ └── RecipeRepository.swift (protocol / interface)
├── Application/
│ └── UseCases/
│ └── SearchRecipeUseCase.swift
├── Infrastructure/
│ ├── Repositories/
│ │ ├── CoreDataRecipeRepository.swift (ローカル DB)
│ │ └── ApiRecipeRepository.swift (リモート API)
│ ├── Storage/
│ │ └── KeyValueStore.swift
│ └── Sync/
│ └── RecipeSyncService.swift
└── Presentation/
├── ViewModels/
│ └── RecipeListViewModel.swift
└── Views/
└── RecipeListView.swift (SwiftUI)
ここでは前章までの整理に合わせて、UseCase は Application 層に置き、Domain 層はモデルとそのルールに集中させる 形にしています。
オフライン対応で Repository が活きる例
// Domain 層:複数の永続化先を抽象化
protocol RecipeRepository {
func find(id: RecipeId) async throws -> Recipe?
func save(_ recipe: Recipe) async throws
}
// Infrastructure 層:ローカル + リモートの合成
class HybridRecipeRepository: RecipeRepository {
let local: CoreDataRecipeRepository
let remote: ApiRecipeRepository
func find(id: RecipeId) async throws -> Recipe? {
// オフラインでもローカルから取得できる
if let cached = try await local.find(id: id) {
return cached
}
let remote = try await remote.find(id: id)
if let recipe = remote {
try await local.save(recipe) // キャッシュ更新
}
return remote
}
}
オフライン対応のロジックが Repository の 実装の中に閉じている ので、ViewModel やドメインは「ただ取得する」だけ。これが DDD の威力です。
React Native / Flutter での適用
クロスプラットフォームでも考え方は同じです。むしろ 共通の Domain 層を Web とアプリで共有できる のが大きな利点。
shared/
└── domain/ ← Web と Mobile で共通
├── recipe.ts
└── recipe.repository.ts
apps/
├── web/
│ └── infrastructure/
│ └── api-recipe.repository.ts
└── mobile/
└── infrastructure/
└── sqlite-recipe.repository.ts (offline first)
Domain が 何にも依存していない からこそ実現できる構造で、まさに DDD の真骨頂と言えます。
ただし「軽量化」を忘れない
サーバーサイドの DDD をフルセットで持ち込むのは過剰 です。スマホアプリでは:
- 集約の境界はサーバーほど厳格でなくてよい(整合性は最終的にサーバーが担保)
- ドメインイベントはローカルなイベント通知 程度で十分
- CQRS まで導入する必要はないことが多い
「サーバーで作ったドメインモデルの クライアント版を薄く保つ」くらいが現実的なバランスです。
CRUD・管理画面と DDD の共存
ここまで読んで、「うちのアプリは設定画面とか管理画面とかが大半なんだけど、それでも DDD?」と思った方も多いはず。その違和感は正しい です。
DDD は 複雑なドメインのためのもの。複雑さがないところに持ち込むと、コードが無駄に長くなるだけです。
ドメインの「重さ」を見極める
1 つのプロジェクトの中でも、機能ごとに複雑さは違います。
| 機能 | 複雑さの中身 | 実装方針 |
|---|---|---|
| 受注処理 | 在庫引当、価格計算、複雑な状態遷移 | DDD で丁寧に扱う |
| 在庫管理 | 引当、戻し、整合性維持 | DDD で丁寧に扱う |
| 商品マスタ管理 | ほぼ CRUD | 軽量でよい |
| ユーザー設定 | ただの値の保存 | CRUD で十分 |
| 管理画面のログ閲覧 | クエリして表示するだけ | CRUD で十分 |
ここで重要なのは、「コア・支援・汎用」の分類が、そのまま実装の重さに対応する ということです。前の章で出てきた話を実装に落とすと、こうなります。
| ドメイン分類 | 実装スタイル | 例 |
|---|---|---|
| コアドメイン | フル DDD | 受注、在庫、価格戦略 |
| 支援サブドメイン | 軽量 DDD or トランザクションスクリプト | 商品マスタ、配送設定 |
| 汎用サブドメイン | CRUD、または SaaS | 認証、メール送信、設定画面 |
CRUD 部分はどう書くか
軽い部分は素直にトランザクションスクリプトで書いて構いません。
// ✅ 設定画面の更新は、これで十分
class UpdateSiteSettingsHandler {
constructor(private readonly db: Database) {}
async execute(input: UpdateSiteSettingsInput): Promise<void> {
await this.db.siteSettings.update({
where: { id: 1 },
data: input,
});
}
}
ここに無理矢理 Entity・Repository・Domain Service を入れても、コード量が増えるだけで何も得しません。
同じプロジェクトで両者を混在させる
実務では コンテキストごとに実装スタイルを変える のが現実解です。
src/contexts/
├── ordering/ ← フル DDD
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── presentation/
├── inventory/ ← フル DDD
│ └── (同上)
├── catalog/ ← 軽量 DDD(Entity だけは作るが Service は最小)
│ └── (簡略版)
└── admin/ ← CRUD ベース
├── handlers/ (薄いハンドラだけ)
└── controllers/
コンテキストの中で一貫していれば OK。プロジェクト全体で統一する必要はありません。
管理画面の Read 専用 API は CQRS が便利
管理画面でよくある「色んな集約を結合して一覧表示したい」というケースは、集約をまたいだ取得 になりがちで、DDD の集約原則と相性が悪いです。
ここで便利なのが CQRS の軽量な導入:書き込み(Command)は集約経由で厳格に、読み取り(Query)は素の SQL で OK と割り切る方法です。
// 書き込みは集約経由(厳格)
class CancelOrderUseCase {
async execute(orderId: OrderId): Promise<void> {
const order = await this.orderRepository.findById(orderId);
order.cancel(); // ドメインルール
await this.orderRepository.save(order);
}
}
// 読み取り専用クエリは素の SQL で OK(画面に最適化)
class OrderListQuery {
async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
return this.db.raw(`
SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
FROM orders o
JOIN users u ON ...
JOIN order_items oi ON ...
WHERE ...
`);
}
}
集約を 1 つずつ取り出してメモリ上で結合…なんてやらないこと。Read には Read の道具を使う のがプラグマティックです。
CRUD と DDD を混ぜるときの原則
最後に、混在させるときの原則をまとめます。
- コンテキスト境界はちゃんと引く — DDD でない部分でも境界の意識は持つ
- コアドメインはケチらない — ここをサボると後で必ず後悔する
- CRUD 部分は素直に書く — 無理に DDD 化しない
- 読み取りは別ルートを許容 — Read Model や CQRS Lite を活用
- 後から複雑化したら昇格 — 最初 CRUD だったものが複雑化したら、その時 DDD に移行する
「DDD は宗教ではなく道具」。ドメインの複雑さに見合った投資をする、という当たり前の原則を守りましょう。
よくある落とし穴
1. ドメインモデル貧血症
Entity がただの getter / setter の塊になり、ロジックが Service に染み出してしまう状態。「ふるまいのないオブジェクトはオブジェクトではない」 を心に刻みましょう。
2. すべてに DDD を適用する
設定画面、管理画面、簡単な CRUD…これらに DDD を持ち込むと開発速度が確実に落ちます。複雑なドメインがあるところにだけ 持ち込むこと。前章「CRUD・管理画面と DDD の共存」も参照してください。
3. 戦術だけをつまみ食いする
「Entity と Repository を作ったから DDD」ではありません。ユビキタス言語と境界づけられたコンテキストの議論を抜きにしては、戦術的パターンは効果を発揮しません。
DDD を始めるためのステップ
実務で DDD を導入したいなら、いきなりフルスペックではなく段階的に取り組むのがおすすめです。
- 業務の言葉を集める — ドメインエキスパートと一緒に用語集を作る
- 境界を意識する — どこまでが同じ文脈の話なのかを線引きする
- Value Object から始める — プリミティブ型を意味のある型に置き換える
- 集約を見極める — トランザクション境界を意識する
- イベントで疎結合に — 必要に応じてドメインイベントを導入する
おわりに
DDD の本質は 「ドメインの複雑さに、コードで真正面から向き合う」 ことだと個人的には思っています。
パターンを覚えることが目的化してしまうと、かえってコードが複雑になりがち。ユビキタス言語をチームで育てること こそが DDD のスタート地点であり、ゴールでもあります。
明日からのコードレビューで、「このクラス名、業務の人にも通じる?」と一言問いかけてみるところから始めてみてはいかがでしょうか。
参考文献
- Eric Evans『エリック・エヴァンスのドメイン駆動設計』
- Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
- Vaughn Vernon『実践ドメイン駆動設計』
- 成瀬允宣『ドメイン駆動設計入門』
- 増田亨『現場で役立つシステム設計の原則』
- Martin Fowler "Patterns of Enterprise Application Architecture"
- Robert C. Martin "Clean Architecture"
- Alberto Brandolini "Introducing EventStorming"
