ドメイン駆動設計

目次

概要

業務の言葉を、ソフトウェアの構造に反映する

ドメイン駆動設計(DDD)は、複雑な業務領域を理解し、その理解をソフトウェアのモデルに反映するための設計アプローチです。クラスの作り方だけではなく、チームが同じ言葉で業務を理解し、境界を切り、変化に強いモデルを育てることを重視します。

要点

DDDの中心は「業務の重要な概念を、コードと会話の両方で同じ形に保つこと」です。

この章で重視すること

  • DDDをレイヤードアーキテクチャの一種だけだと思わない
  • 言葉、境界、モデルを一緒に扱う
  • 集約をトランザクション境界として理解する
  • イベント駆動設計やAPI設計との接続を見る

DDDとは何か

DDDは、複雑なドメインを扱うための設計思想です。

flowchart LR Expert["ドメイン専門家"] Language["ユビキタス言語"] Model["ドメインモデル"] Code["コード"] Expert <--> Language Language <--> Model Model <--> Code

重要なのは、コードだけで完結しないことです。業務理解と実装を往復しながらモデルを改善します。

ドメインとモデル

ドメインとは、ソフトウェアが扱う業務領域です。モデルとは、その領域を理解し操作するための抽象化です。

例:

ドメイン 重要な概念
EC 商品、注文、在庫、決済、配送
予約 空き枠、予約、キャンセル、利用者
請求 請求書、支払い、締め日、消込

モデルは現実の完全な写しではありません。目的に合わせて切り取ったものです。

ユビキタス言語

ユビキタス言語は、ドメイン専門家と開発者が共有する言葉です。

悪い例:
  UserData
  Status = 3
  process()

よい例:
  Customer
  OrderConfirmed
  reserveInventory()

名前が業務の意味を表すと、会話とコードが近づきます。

Martin Fowlerは、Ubiquitous Languageを「開発者と利用者の間で作り上げる共通で厳密な言語」として説明しています。この言語は会話だけでなく、クラス名、関数名、API名、イベント名にも現れます。

ユビキタス言語は用語集を作れば完成するものではありません。会議で使う言葉、画面に出る言葉、コードに出る名前、ログやイベント名がずれていると、同じ単語でも違うものを指すようになります。

場所 確認すること
会話 ドメイン専門家と開発者が同じ意味で使っているか
UI 利用者が業務で使う言葉になっているか
コード クラス名やメソッド名が業務概念を表しているか
API 外部へ出す名前がコンテキストを越えて誤解されないか
イベント 起きた事実を過去形で表しているか

名前を直すことは小さなリファクタリングに見えますが、DDDでは重要な設計改善です。名前が曖昧なまま設計を進めると、責務の境界も曖昧になります。

境界づけられたコンテキスト

同じ言葉でも、文脈によって意味が変わることがあります。

文脈 「顧客」の意味
販売 商品を購入する人
サポート 問い合わせをする人
請求 支払い責任を持つ契約主体

境界づけられたコンテキストは、言葉とモデルが一貫する範囲です。

flowchart LR Sales["販売コンテキスト"] Support["サポートコンテキスト"] Billing["請求コンテキスト"] Sales -. "顧客ID" .-> Billing Support -. "問い合わせ履歴" .-> Sales

大きなドメインで単一の統一モデルを保つことは難しくなるため、モデルが一貫する範囲を明示的に分けることが重要です。

エンティティと値オブジェクト

エンティティは同一性を持つオブジェクトです。値オブジェクトは値そのものが意味を持ちます。

種類 判断基準
エンティティ IDで同一性を追う 顧客、注文、契約
値オブジェクト 値が同じなら同じ 金額、住所、期間

値オブジェクトを使うと、プリミティブな文字列や数値に意味を持たせられます。

集約

集約は、一貫性を守る単位です。集約ルートを通して内部を変更します。

flowchart TD Order["注文 集約ルート"] Item["注文明細"] Address["配送先"] Payment["支払い状態"] Order --> Item Order --> Address Order --> Payment

集約を大きくしすぎるとロックや更新競合が増えます。小さくしすぎると一貫性を守りにくくなります。

集約を決めるときは、オブジェクトの親子関係ではなく、同時に守るべき不変条件を見ます。注文と注文明細は「合計金額は明細の合計と一致する」のような不変条件を共有するため、同じ集約に置く理由があります。一方で、配送追跡や請求履歴まで同じ集約に入れると、更新頻度も責務も広がりすぎます。

判断 大きすぎる集約 小さすぎる集約
更新競合 起きやすい 起きにくい
不変条件 守りやすいが重い 分散して守りにくい
読み取り まとめて取りやすい 組み合わせが必要
変更理由 混ざりやすい 局所化しやすい

実装では、集約の外から内部のコレクションを自由に変更できる形にしないことが重要です。集約ルートのメソッドを通して状態を変えると、業務ルールを1か所に集めやすくなります。

ドメインサービス

エンティティや値オブジェクトに自然に置けない業務ロジックは、ドメインサービスとして表します。

例:

  • 複数口座間の送金
  • 複数倉庫をまたぐ在庫引当
  • 複数契約をまたぐ割引計算

ただし、何でもサービスに入れると貧血ドメインモデルになります。まずは対象の概念に振る舞いを置けないか考えます。

リポジトリ

リポジトリは、集約を保存・取得するための抽象です。

Application Service
  -> OrderRepository
    -> Database

ドメインモデルがSQLやORMの詳細に強く依存しないようにするための境界です。

ドメインイベント

ドメインイベントは、業務上意味のある出来事です。

  • 注文が確定された
  • 支払いが失敗した
  • 在庫が引き当てられた
  • 契約が更新された

イベント名は過去形にすると扱いやすくなります。イベント駆動設計と接続する場合も、まずはドメイン上の意味を明確にします。

コンテキストマップ

コンテキストマップは、複数のコンテキストの関係を表します。

関係 意味
Shared Kernel 一部のモデルを共有
Customer/Supplier 上流と下流の依存関係
Conformist 下流が上流モデルに従う
Anti-Corruption Layer 外部モデルを変換して守る

外部サービスやレガシーシステムとつなぐときは、Anti-Corruption Layerが重要になります。

コンテキストマップは、マイクロサービス分割図ではありません。重要なのは、モデル同士がどのような力関係でつながっているかです。上流のモデル変更を下流が受け入れるしかないのか、互いに合意して共有部分を管理するのか、外部モデルを変換して守るのかで、必要な設計は変わります。

flowchart LR Legacy["レガシー請求"] ACL["Anti-Corruption Layer"] Billing["新請求コンテキスト"] Sales["販売コンテキスト"] Support["サポートコンテキスト"] Legacy --> ACL --> Billing Sales --> Billing Billing --> Support

この図では、新しい請求コンテキストがレガシー請求の言葉に引きずられないようにACLを置いています。変換層を置かずに直接つなぐと、レガシー側の制約や命名が新しいモデルへ入り込み、境界づけられたコンテキストが崩れます。

境界とチーム設計

境界づけられたコンテキストは、コードの境界であると同時にコミュニケーションの境界でもあります。複数チームで開発する場合、コンテキストをまたぐ変更が頻発するなら、境界が実際の業務やチーム構造に合っていない可能性があります。

よい兆候:
  - そのコンテキスト内で言葉が一貫している
  - 変更理由がまとまっている
  - 他チームとの調整なしに小さな変更を進められる

悪い兆候:
  - 同じ言葉がチームごとに違う意味で使われる
  - 小さな変更で複数サービスを同時に直す
  - DB共有が境界を曖昧にしている

境界発見の進め方

境界づけられたコンテキストは、最初から正解として見つかるものではありません。Martin FowlerのBounded Context解説でも、巨大な統一モデルを維持する難しさと、言葉が変わる場所を境界として明示する重要性が強調されています。Microsoftのマイクロサービス向けドメイン分析でも、サービス境界は一度決めて終わりではなく、業務理解と非機能要件に合わせて継続的に見直すものとして扱われています。

flowchart LR Domain["業務領域を洗い出す"] Events["重要な出来事を並べる"] Language["言葉の違いを見つける"] Context["境界づけられたコンテキスト"] Model["境界内のモデル"] Service["必要ならサービス境界へ"] Feedback["運用・変更から学ぶ"] Domain --> Events --> Language --> Context --> Model --> Service --> Feedback --> Language

境界を探すときは、エンティティ名だけでなく、意思決定、変更理由、データ所有、チーム間調整を見ます。

観点 見ること 境界を疑うサイン
言葉 同じ単語が同じ意味で使われているか 顧客商品注文 の意味が部署で違う
ルール 同じ不変条件を守っているか 片方の変更で別領域のルールが壊れる
変更理由 同じタイミングで変わるか 小さな変更で複数チームの同時作業が必要
データ所有 どのモデルが正本を持つか DB共有で責任が曖昧になっている
非機能要件 可用性、性能、監査、セキュリティが同じか 一部だけ強いSLOや監査要件を持つ
外部連携 外部モデルに引きずられていないか 外部APIの命名や制約がドメインモデルへ漏れている

実務では、境界を先にサービスとして固定しない方が安全です。まずコンテキストとして言葉と責務を分け、変更頻度、トランザクション、チーム所有、運用負荷を見て、必要な部分だけをサービス境界にします。単一アプリケーションの中に複数の境界づけられたコンテキストがあっても構いません。

要点

DDDの境界は、クラス図からではなく、業務の言葉、変更理由、所有権、非機能要件から見つけます。マイクロサービス化は結果であって、出発点ではありません。

DDDを使うべき場面

DDDは万能ではありません。向いているのは、業務ルールが複雑で、長期的に変化する領域です。

向いている:

  • 業務ルールが多い
  • 言葉の意味が文脈で変わる
  • 長期保守が前提
  • ドメイン専門家との対話ができる

向いていない:

  • 単純なCRUD
  • 短命なプロトタイプ
  • 業務ルールがほとんどない
  • 技術検証だけのコード

よくある誤解

  • DDDは特定のフォルダ構成ではない
  • エンティティを作ればDDDではない
  • すべてをイベントソーシングにする必要はない
  • DDDはマイクロサービス専用ではない
  • 複雑でない領域に使うと過剰設計になる

戦略的設計と戦術的設計

EvansのDDD Referenceでは、DDDの語彙は単なる実装パターンではなく、複雑なドメインを扱うための設計言語として整理されています。実務では、まず戦略的設計で境界を見つけ、その後で戦術的設計に進む方が失敗しにくくなります。

目的
戦略的設計 モデルの境界を決める 境界づけられたコンテキストコンテキストマップ
戦術的設計 境界内のモデルを作る エンティティ、値オブジェクト、集約、リポジトリ

集約やリポジトリから始めると、フォルダ構成の話に寄りがちです。先に「この言葉はどの文脈で同じ意味か」「どのチームがどのモデルを守るか」を決めると、実装パターンの使いどころも見えやすくなります。

実装パターンと具体例

値オブジェクトの実装戦略

値オブジェクトはドメイン言語を型システムで強制するための強力な手法です。単純なプリミティブ型の代わりに、セマンティックに意味のある型を使うことで、不正な状態を構造的に排除できます。

// 悪い例: プリミティブ型の乱用
public class Order {
  public int customerId;
  public decimal amount;
  public string status;
}

// よい例: 値オブジェクトで意味を強制
public class Order {
  public CustomerId CustomerId { get; }
  public Money Amount { get; }
  public OrderStatus Status { get; }
}

public record Money(decimal Value) {
  public Money {
    if (Value < 0) throw new InvalidOperationException("金額は負数不可");
  }
}

値オブジェクトを使うことで、不正な金額(負数)や不正なステータス遷移をコンパイル時に捉えることができます。

集約の境界設定: 実務的判断基準

集約の大きさは、次の3つの観点で判断します。

1. 不変条件の共有

注文と注文明細は「合計金額 = 明細の合計」という不変条件を共有するため、同じ集約に置きます。

public class Order {
  private List<OrderLine> lines = new();
  
  public void AddLine(Product product, int quantity) {
    lines.Add(new OrderLine(product, quantity));
    // 不変条件: 合計金額を常に正確に保つ
  }
  
  public decimal TotalAmount => lines.Sum(l => l.Subtotal);
}

2. トランザクション境界

同じ集約内の変更は1つのトランザクションで完結する必要があります。複数の集約にまたがる変更はドメインイベント経由で調整します。

// 1つの集約: 在庫と予約を同時に更新
public class InventoryAggregate {
  private int Available { get; set; }
  
  public void Reserve(int quantity) {
    if (Available < quantity) throw new InsufficientInventoryException();
    Available -= quantity;
  }
}

// 複数集約: ドメインイベントで調整
public class OrderAggregate {
  public event OrderConfirmed? OnConfirmed;
  
  public void Confirm() {
    // イベント発火 -> InventoryServiceが購読して在庫を引き当て
    OnConfirmed?.Invoke(this);
  }
}

3. 変更頻度と競合の度合い

更新頻度が異なるとロック競合が増えます。頻繁に変わる要素は分離します。

Anti-Corruption Layer (ACL) の実装

外部システムやレガシーコードとの統合では、ACLがコンテキストを守る防波堤になります。

// 外部API: 古い形式
public class LegacyCustomerApi {
  public class Customer {
    public string Id { get; set; }
    public string FullName { get; set; }
    public string Status { get; set; } // "1", "2", "3"
  }
}

// ACL: 変換層
public class CustomerAdapter {
  private readonly LegacyCustomerApi api;
  
  public DomainCustomer GetCustomer(CustomerId id) {
    var legacy = api.GetCustomer(id.Value);
    
    return new DomainCustomer(
      id: CustomerId.From(legacy.Id),
      name: PersonName.From(legacy.FullName),
      status: ConvertStatus(legacy.Status)
    );
  }
  
  private CustomerStatus ConvertStatus(string legacyStatus) => legacyStatus switch {
    "1" => CustomerStatus.Active,
    "2" => CustomerStatus.Suspended,
    "3" => CustomerStatus.Inactive,
    _ => throw new InvalidOperationException({{CONTENT}}quot;未知のステータス: {legacyStatus}")
  };
}

ドメインイベントの設計

ドメインイベントは業務上の意味のある出来事を記録し、複数のコンテキストを疎結合に保つための仕組みです。

public abstract record DomainEvent(
  EventId Id,
  DateTime OccurredAt,
  AggregateId AggregateId
);

public record OrderConfirmed(
  EventId Id,
  DateTime OccurredAt,
  OrderId OrderId,
  CustomerId CustomerId,
  Money TotalAmount
) : DomainEvent(Id, OccurredAt, OrderId);

public record PaymentFailed(
  EventId Id,
  DateTime OccurredAt,
  OrderId OrderId,
  string Reason
) : DomainEvent(Id, OccurredAt, OrderId);

イベントは単なる通知ではなく、集約の状態変化の記録です。イベントソーシングを採用すれば、すべての状態遷移を完全に追跡できます。

DDDと責務駆動設計

RDDは「オブジェクトにその知識や責務を与える」という原則です。DDDでは、エンティティや値オブジェクトに業務ルールを配置することで、ドメイン言語をコードに反映します。

public class Order {
  // 責務: 注文の確定可能性を判定する
  public bool CanConfirm() => Status == OrderStatus.Pending && HasPayment;
  
  // 責務: 自分自身を確定させる
  public void Confirm() {
    if (!CanConfirm()) throw new InvalidOperationException();
    Status = OrderStatus.Confirmed;
  }
}

DDDとHexagonal Architecture

Hexagonal Architectureは、ドメイン層を外部への依存から守る構造です。このアーキテクチャ内では、DDDのエンティティと値オブジェクトがドメイン層に完全に独立して存在し、永続化や通信の詳細がアダプター層に隔離されます。このアーキテクチャにより、ドメイン層への外部依存を最小化し、ビジネスロジックを独立してテストできるようになります。

DDDにおけるアンチパターンと対策

実務ではDDDの原則を完全に守れないことも多くあります。よく見かけるアンチパターンと対策を理解することで、プロジェクトに適切にDDDを適用できます。

アンチパターン1:ドメインロジックがRepositoryに散らばる

悪い例:

// Repository内にドメインロジックが混在
public class OrderRepository {
  public Order GetOrderWithDiscount(OrderId id) {
    var order = db.Orders.First(o => o.Id == id);
    if (order.CustomerType == "VIP") {
      order.TotalPrice *= 0.8m;  // ドメインロジック!
    }
    return order;
  }
}

問題点:

  • ビジネスルール(VIP割引)がRepositoryに散らばる
  • テスト時にDBアクセスが必要
  • 同じロジックが複数箇所に重複するリスク

良い例:

public class Order {
  public decimal ApplyVipDiscount() {
    if (CustomerType == "VIP") {
      return TotalPrice * 0.8m;
    }
    return TotalPrice;
  }
}

public class OrderRepository {
  public Order GetOrder(OrderId id) {
    return db.Orders.First(o => o.Id == id);
  }
}

// 使用側
var order = repository.GetOrder(id);
var finalPrice = order.ApplyVipDiscount();  // ドメインロジック

アンチパターン2:値オブジェクトがimmutableでない

悪い例:

public class Money {
  public decimal Amount { get; set; }  // mutable!
}

var price = new Money { Amount = 100m };
price.Amount = 50m;  // 変更可能 - 不変性が失われた

問題点:

  • 予期しない値の変更
  • マルチスレッド環境でのdata race
  • キャッシュ無効化の手間

良い例:

public readonly struct Money {
  private readonly decimal _amount;
  
  public Money(decimal amount) {
    if (amount < 0) throw new ArgumentException("Amount must be non-negative");
    _amount = amount;
  }
  
  public static Money operator +(Money a, Money b)
    => new Money(a._amount + b._amount);
}

var price = new Money(100);
// price._amount = 50;  // コンパイルエラー - 変更不可
var newPrice = price + new Money(10);  // 新しいインスタンスが返される

アンチパターン3:集約の粒度が大きすぎる

悪い例:Order集約の中にすべてが含まれる:

public class Order {
  public OrderId Id { get; set; }
  public List<OrderLine> Lines { get; set; }
  public Customer Customer { get; set; }
  public List<Payment> Payments { get; set; }
  public List<Shipment> Shipments { get; set; }
  public List<Return> Returns { get; set; }
  // ... さらに30個のプロパティ
}

問題点:

  • 変更が複雑で、並行処理でロック競合
  • Customerの変更でOrder全体をロード・保存
  • テストが複雑
  • トランザクション負荷が高い

良い例:複数の小さな集約に分割:

public class Order {  // 集約ルート
  public OrderId Id { get; set; }
  public OrderStatus Status { get; set; }
  public List<OrderLine> Lines { get; set; }
  public Money TotalAmount { get; set; }
}

public class Payment {  // 独立した集約
  public PaymentId Id { get; set; }
  public OrderId OrderId { get; set; }
  public Money Amount { get; set; }
}

public class Shipment {  // 独立した集約
  public ShipmentId Id { get; set; }
  public OrderId OrderId { get; set; }
  public ShipmentStatus Status { get; set; }
}

Benefits:

  • 各集約を独立して変更・テスト
  • 並行処理でのロック競合を減少
  • イベント駆動で疎結合に

実務でのDDD適用パターン

完全なDDD実装は多くの機能を要求します。実務では、コストとメリットのバランスをとる必要があります。

Pattern1:ドメインが単純な場合(CRUD主体)

適用:エンティティのみ、簡易な値オブジェクト

public class User {
  public int Id { get; set; }
  public string Name { get; set; }
  public string Email { get; set; }
}
// ドメインロジックはほぼ無い

実装コスト:低
メリット:少ない

Pattern2:ドメインが複雑な場合(複数チームが関わる)

適用:ユビキタス言語、複数のBoundedContext、イベント駆動

実装コスト:高
メリット:チーム間のコミュニケーション効率が大幅向上、ドメイン理解の統一

Pattern3:既存レガシーコードのDDD化(段階的モダナイズ)

戦略:

  1. 新機能から小さなBoundedContextで開発
  2. Anti-Corruption Layerで既存コードとの境界を作成
  3. 段階的に既存コードをマイグレート
public class OrderService_NewContext {  // 新しいContext
  private readonly IOrderRepository_Legacy _legacy;
  
  public void ProcessOrder(OrderCommand cmd) {
    // Anti-Corruption Layer: 変換
    var order = ConvertToNewModel(_legacy.GetOrder(cmd.OrderId));
    // 新しいドメインロジック適用
    order.Apply(cmd);
    // 結果を古いシステムに通知
    _legacy.UpdateOrder(ConvertToLegacy(order));
  }
}

DDDの測定:Code Metrics

ドメイン駆動設計がうまく機能しているかを測定する指標:

指標 良い状態 警告
Cyclomatic Complexity < 10 > 20
クラスサイズ(行数) < 150 > 300
メソッドサイズ < 20行 > 50行
依存性の深さ < 5層 > 10層
テストカバレッジ > 80% < 60%
リリース間隔 < 2週間 > 3ヶ月

これらのメトリクスが悪化し始めたら、以下を検討:

  • 集約の粒度見直し
  • BoundedContextの再分割
  • リファクタリング

DDDツール・フレームワーク

C#/.NET

  • Domain Events Pattern: MediatR, EventFlow
  • Event Sourcing: EventStoreDB, Marten
  • Validation: FluentValidation

Java

  • Axon Framework: イベント駆動とCQRS
  • Spring Data: Repositoryパターン実装
  • jMolecules: DDD annotations

DDD記述ツール

  • Event Storming: グループでドメイン理解を深める
  • Context Mapping Canvas: BoundedContext間の関係を可視化
  • Domain Story Telling: ドメインストーリーのシナリオ記述

パフォーマンスとDDD

DDDは設計の正確性を優先しますが、パフォーマンスを見落とさない必要があります。

問題:集約ロードのオーバーヘッド

// 悪い例:Order全体をロード
var order = repository.GetOrder(id);  // 100KB、複雑なオブジェクトグラフ
order.AddLine(line);  // 1行追加するのに100KB読み込み

対策1:Query Model(CQRS)

// 読み取り用の単純なモデル
public class OrderSummary {
  public OrderId Id { get; set; }
  public decimal TotalAmount { get; set; }
  public int LineCount { get; set; }
}

var summary = queryRepository.GetOrderSummary(id);  // 1KB

対策2:Lazy Loading

public class Order {
  private List<OrderLine> _lines;
  
  public List<OrderLine> Lines {
    get {
      _lines ??= db.OrderLines.Where(l => l.OrderId == Id).ToList();
      return _lines;
    }
  }
}

対策3:イベント駆動での非同期処理

public class Order {
  public void Confirm() {
    // コア処理は同期
    this.Status = OrderStatus.Confirmed;
    
    // 重い処理は非同期イベント
    RaiseEvent(new OrderConfirmed(Id, ConfirmedAt));
  }
}

// イベントハンドラ
public class OrderConfirmedHandler {
  public async Task Handle(OrderConfirmed evt) {
    // 重い処理:メール送信、在庫更新、分析データ出力
    await _emailService.SendConfirmation(evt.OrderId);
  }
}

イベント駆動アーキテクチャとDDD

複雑なドメインを複数のマイクロサービスに分割する場合、イベント駆動パターンがDDDと相性が良くなります。

イベント駆動での集約間通信

Order 集約でイベント発行し、他のサービスが反応するパターン。

public class Order {
  public OrderId Id { get; }
  private List<DomainEvent> _events = new();
  
  public void Confirm() {
    this.Status = OrderStatus.Confirmed;
    RaiseEvent(new OrderConfirmed(Id, DateTime.UtcNow));
  }
  
  protected void RaiseEvent(DomainEvent evt) {
    _events.Add(evt);
  }
}

public class OrderConfirmedHandler {
  public async Task Handle(OrderConfirmed evt) {
    var payment = await _paymentService.Process(evt.OrderId);
    if (payment.Succeeded) {
      _eventBus.Publish(new PaymentProcessed(evt.OrderId));
    }
  }
}

イベントソーシング

ドメインイベントをすべて保存し、状態を再構築する。現在の状態ではなく、その状態に至った経過を記録。

var events = eventStore.GetEventsFor(orderId);
var order = new Order();

foreach (var evt in events) {
  order.Apply(evt);
}

public class Order {
  public void Apply(DomainEvent evt) {
    switch (evt) {
      case OrderCreated e:
        this.Id = e.OrderId;
        this.Status = OrderStatus.Pending;
        break;
      case PaymentProcessed e:
        this.Status = OrderStatus.Paid;
        break;
      case OrderShipped e:
        this.Status = OrderStatus.Shipped;
        break;
    }
  }
}

メリット:完全な監査ログ、過去の状態を復元可能、イベントリプレイで並行処理のバグ検出。

ドメインモデルの進化

ドメインモデルは、チーム内で共同で設計される必要があります。Event Storming は、このプロセスを体系化したテクニック。

Event Storming のワークショップ

  1. Domain Event の抽出:起こりうるすべてのイベントを付箋に書き出す
  2. Timeline の作成:時系列でイベントを並べる
  3. Bounded Context の特定:どのコンテキストで何が起こるか
  4. 集約の特定:各イベントの source を特定

参加者:ドメイン専門家、開発者、プロダクトマネージャー

結果:チーム全体でドメインを理解し、ユビキタス言語を共有。

テスト可能性とDDD

DDDのモデルを適切に設計すると、テストが容易になります。ドメインロジックが独立しているため、DBアクセスなしにテスト可能。

[TestClass]
public class OrderTests {
  [TestMethod]
  public void ApplyDiscount_ValidCode_ReducesPrice() {
    var order = new Order(
      id: new OrderId(1),
      items: new[] { new OrderLine(productId: 1, quantity: 2, unitPrice: 100m) }
    );
    var discountCode = new DiscountCode("SUMMER20");
    
    order.ApplyDiscount(discountCode);
    
    Assert.AreEqual(160m, order.TotalPrice);
  }
}

実務でのドメイン モデリング プロセス

大規模プロジェクトでドメインモデリングを成功させるには、継続的な取り組みが必要です。

段階1:ドメイン理解の深化

  • Domain Expert へのインタビュー(複数人)
  • 既存ドメイン知識の文書化
  • 用語の統一(ユビキタス言語の確立)
  • 例外ケースの把握

段階2:Bounded Context の発見

マイクロサービスに分割する前に、論理的な境界を明確化します。

例:eコマース

  • Order Context(注文管理)
  • Payment Context(決済)
  • Inventory Context(在庫)
  • Shipping Context(配送)

各Context は独立したデータベース、言語定義を持つことで、チーム間の齟齬を減らせます。

段階3:継続的なリモデリング

ビジネスの変化に応じてモデルを進化させます。3~6ヶ月ごとに Event Storming ワークショップを開催し、新しい要件を反映。

DDD のスケーリング

大規模組織での適用

数百人の開発者が関わるプロジェクトでDDDを適用した例:Amazon, Google。

要点:

  • 各チームが所有するBounded Context は独立
  • Context 間通信は API 経由
  • データの重複を許容(eventual consistency)
  • ドメイン言語はContext 内で局所的

時間軸での進化

最初は1つの大きな Context から始め、ビジネス成長に合わせて分割。

Year 1: Monolithic single context Year 2: Order, Payment分離 Year 3: さらに Shipping, Notification分離

段階的な進化により、チーム規模と複雑度がバランス。

DDD と マイクロサービス

マイクロサービスアーキテクチャを採用する場合、DDDの Bounded Context が自然な分割単位になります。

サービス境界

Bounded Context → Microservice の mapping:

  • Order Service(Order Context)
  • Payment Service(Payment Context)
  • Inventory Service(Inventory Context)

各サービス:

  • 独立した database
  • 異なるプログラミング言語可(polyglot)
  • 独立したデプロイ
  • イベント駆動で通信

データ整合性戦略

Distributed system では strong consistency が困難:

  • Eventual Consistency を採用
  • Saga pattern で distributed transaction
  • Event sourcing で全ての状態変化を記録
  • CQRS で読み書きのモデルを分離

実装例:Order を作成するとき:

  1. Order Service が OrderCreated event を emit
  2. Payment Service が event を listen
  3. Payment Service が PaymentProcessed event を emit
  4. Inventory Service が event を listen
  5. Inventory Service が InventoryReserved event を emit

各サービスが非同期で処理。一つのサービスがダウンしても、event queue により後で処理可能。

DDDサービスのテスト

マイクロサービスのテスト:

  1. Unit test:各サービスの business logic
  2. Contract test:Service 間の interface
  3. Integration test:複数サービスの interaction
  4. End-to-end test:全体の workflow

各層でテストすることで、問題を早期に検出。

組織との整合

Conway’s Law:“組織の構造は、作るシステムの構造に反映される”

DDD と組織設計を合わせると、効率が向上:

Domain Expert → Team A → Order Service
Domain Expert → Team B → Payment Service
Domain Expert → Team C → Inventory Service

チームが自分の context を deep に理解 → quality 向上。

集約(Aggregate)の詳細設計

集約は DDD の最も実装的な概念。複数のエンティティ・値オブジェクトを「トランザクション境界」でまとめたもの。

集約の設計原則

1. 集約ルートはエンティティ

集約の「入口」となる主エンティティ。外部は集約内の他のオブジェクトを直接参照できず、必ずルートを通す。

// 注文集約の例
public class Order {  // ← 集約ルート
    private OrderId id;
    private CustomerId customerId;
    private List<OrderLine> lines;  // ← 外部からは直接アクセス不可
    private OrderStatus status;
    
    // 外部は Order(ルート)を通してのみ操作
    public void addLine(Product product, Quantity qty) {
        if (this.status != OrderStatus.DRAFT) {
            throw new BusinessException("Cannot modify finalized order");
        }
        this.lines.add(new OrderLine(product, qty));
    }
    
    // OrderLine に直接アクセスさせない
    public List<OrderLine> getLines() {
        return Collections.unmodifiableList(this.lines);  // 読み取り専用
    }
}

public class OrderLine {  // ← 集約内のエンティティ
    private LineId id;
    private Product product;
    private Quantity quantity;
    private Price linePrice;
    
    // OrderLine は単独では作成・保存されない
    // Order を通してのみアクセス
}

2. 集約内の参照は ID で、集約間は参照を使用

public class Order {
    private OrderId id;
    private CustomerId customerId;  // ← Customer 集約への ID 参照
    private List<OrderLine> lines;
}

// Customer 集約を必要なら別途ロード
Customer customer = customerRepository.load(customerId);

この設計により:

  • 各集約は独立して保存・更新できる
  • マイクロサービス間の参照も ID ベースになり、疎結合

3. 集約のサイズは小さめに

集約が大きすぎると:

  • トランザクション失敗時の影響範囲が大きい
  • パフォーマンス低下(全体をロードする必要)
  • 並行更新の競合増加

例:1 つの Customer 集約と複数の Order 集約に分割。

Repository パターン

集約を永続化層(DB)から隔離するインターフェース。

public interface OrderRepository {
    void add(Order order);
    void remove(Order order);
    Order findById(OrderId id);
    List<Order> findByCustomer(CustomerId customerId);
}

// 実装(JPA 例)
@Repository
public class JpaOrderRepository implements OrderRepository {
    @Autowired
    private OrderJpaRepository jpaRepo;
    
    @Override
    public void add(Order order) {
        OrderJpaEntity entity = OrderJpaMapper.toPersistence(order);
        jpaRepo.save(entity);
    }
    
    @Override
    public Order findById(OrderId id) {
        OrderJpaEntity entity = jpaRepo.findById(id.value()).orElse(null);
        return entity != null ? OrderJpaMapper.toDomain(entity) : null;
    }
}

Repository は:

  • ドメイン層(Order)と永続化層(DB)を分離
  • テストで fake Repository を注入可能
  • DB 変更(MongoDB → PostgreSQL)が容易

Service(アプリケーションサービス)の責務

サービスはビジネスロジックではなく、「複数の集約を調整」するのが仕事。

@Service
public class PlaceOrderService {
    @Autowired
    private OrderRepository orderRepo;
    
    @Autowired
    private CustomerRepository customerRepo;
    
    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Transactional
    public OrderId placeOrder(CustomerId customerId, List<LineRequest> lines) {
        // 1. Customer 集約をロード
        Customer customer = customerRepo.findById(customerId);
        if (customer == null) {
            throw new EntityNotFoundException("Customer not found");
        }
        
        // 2. Order 集約を作成(ドメインロジック)
        Order order = Order.create(customerId, lines);
        
        // 3. 在庫サービスと連携(外部サービス)
        for (OrderLine line : order.getLines()) {
            if (!inventoryService.reserve(line.getProduct().getId(), line.getQuantity())) {
                throw new OutOfStockException(line.getProduct().getId());
            }
        }
        
        // 4. 支払い処理
        PaymentResult result = paymentService.charge(customer.getPaymentMethod(), order.getTotal());
        
        if (!result.isSuccess()) {
            throw new PaymentFailedException(result.getReason());
        }
        
        // 5. Order を永続化(Repository を通す)
        orderRepo.add(order);
        
        // 6. イベント発行
        order.recordEvent(new OrderPlacedEvent(order.getId(), customerId));
        
        return order.getId();
    }
}

イベント駆動と DDD

集約が重要な状態変化を「イベント」として記録。

Domain Event(ドメインイベント)

public class OrderPlacedEvent {
    private final OrderId orderId;
    private final CustomerId customerId;
    private final Instant occurredAt;
    
    public OrderPlacedEvent(OrderId orderId, CustomerId customerId) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.occurredAt = Instant.now();
    }
}

public class Order {
    private List<DomainEvent> domainEvents = new ArrayList<>();
    
    public void place() {
        // ビジネスロジック
        this.status = OrderStatus.PLACED;
        
        // イベント記録
        this.recordEvent(new OrderPlacedEvent(this.id, this.customerId));
    }
    
    public void recordEvent(DomainEvent event) {
        this.domainEvents.add(event);
    }
    
    public List<DomainEvent> getDomainEvents() {
        return new ArrayList<>(this.domainEvents);
    }
    
    public void clearDomainEvents() {
        this.domainEvents.clear();
    }
}

イベント購読

@Service
public class OrderEventHandler {
    @EventListener
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // 在庫をロック
        System.out.println("Reserving inventory for order " + event.getOrderId());
    }
}

@Service
public class NotificationService {
    @EventListener
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // メール送信
        System.out.println("Sending confirmation to customer " + event.getCustomerId());
    }
}

この設計により:

  • 集約の変更と副作用(メール、ログ)を分離
  • マイクロサービス間の連携が Event-driven に
  • 新しい要件(例:ポイント加算)が追加容易(新ハンドラを登録するだけ)

マイクロサービスと Bounded Context

マイクロサービスアーキテクチャで複数の Bounded Context を分散実装。

コンテキストマッピング

異なる Bounded Context がどう連携するかを明示。

Partnership(協力)

Payment BC ←→ Order BC
(両方同じペースで進化)

Customer-Supplier(上下流)

Order BC → Payment BC
(Order が Supplier、Payment が Customer)

Conformist(順応型)

Order BC Shipping BC
Shipping の仕様に Order が合わせる)

Anti-Corruption Layer(防御層)

Order BC → [Adapter] → Legacy System BC
(Legacy の汚れから守る)
// Anti-Corruption Layer の例
@Service
public class LegacyInventoryAdapter {
    @Autowired
    private LegacyInventoryClient legacyClient;
    
    public InventoryStatus checkStock(ProductId productId) {
        // Legacy API が返す形式を ドメインモデルに変換
        String legacyResponse = legacyClient.getStock(productId.value());
        
        // 汚い形式 → ドメインモデル
        int quantity = Integer.parseInt(legacyResponse.split(",")[0]);
        return new InventoryStatus(productId, Quantity.of(quantity));
    }
}

実務的な DDD チェックリスト

チームで DDD を実装するときの確認事項:

  1. ユビキタス言語

    • チーム全体で同じ用語を使っているか?
    • Glossary を保守しているか?
    • 文書・コード・会話で一貫しているか?
  2. Bounded Context 分割

    • 各マイクロサービスは 1 つの BC を代表しているか?
    • BC 間の責任は明確か?
    • Context Map は最新か?
  3. エンティティと値オブジェクト

    • ID を持つべきものはエンティティか?
    • 不変な概念は値オブジェクトか?
    • 値オブジェクトは equals/hashCode を実装しているか?
  4. 集約

    • 集約ルートは明確か?
    • 集約内の他のエンティティへのアクセスは ルート経由か?
    • 集約のサイズは妥当か?
  5. Repository

    • すべての集約ルート用に Repository があるか?
    • Repository はドメイン言語を使っているか?
    • テストで fake Repository を使用可能か?
  6. Domain Event

    • 重要な状態変化はイベント化しているか?
    • イベントは過去形で命名しているか?(PlacedOrder ではなく OrderPlaced)
    • イベントハンドラは独立して追加可能か?
  7. テスト

    • ドメインロジックは単体テストで検証しているか?
    • Repository や Service のテストでは fake 実装を使用しているか?

最終まとめ

DDDの本質は「ビジネスとコードの乖離を減らす」こと。ユビキタス言語、Bounded Context、集約などの構成要素を適切に組み合わせることで、複雑なドメインを manageable にできます。

最初は小さく始めて、ビジネスの成長に合わせてモデルを進化させるのが実務的です。完璧を目指さず、iterativeに改善することが重要。

参考資料と次のステップ

このノートで DDD の基本から実装まで学びました。次は実プロジェクトで試してみることが重要。チームとのコミュニケーション、ドメイン知識の共有、継続的なリモデリングを心がけます。

まとめ

ドメイン駆動設計は、業務の言葉とソフトウェアの構造を近づけるための考え方です。ユビキタス言語境界づけられたコンテキスト、集約、ドメインイベントを使うことで、複雑な業務を変更しやすいモデルとして扱えるようになります。

参考文献

公式・標準

講義・記事