【実践】DDDのドメインモデル設計手順:集約・値オブジェクト・不変条件をJavaで実装する

この記事でわかること

  • ドメインモデルを「どう切って、どう守るか」の設計手順(迷いどころの意思決定)
  • 集約(Aggregate)を壊さないためのルールとチェックリスト
  • Javaで実装する際の型設計(値オブジェクト / ID / 例外 / ファクトリ)

対象読者:DDDの用語は知っているが、実務で「具体的にこの形で作ればよい」を固めたい中級者


前提:ドメインモデル設計のゴールは“変更に強いビジネスルールの箱”を作ること

DDDでドメインモデルを作る目的は、クラス図を美しくすることではありません
ビジネスルール(不変条件)を一箇所に閉じ込め、破られない形で進化させることがゴールです

そのために本記事では、次の順で設計します

  1. 言葉(ユビキタス言語)を揃える
  2. 不変条件(守るべきルール)を列挙する
  3. 集約境界を決める(トランザクション境界)
  4. 値オブジェクトで型を強くする
  5. ドメインサービスの出番を明確にする
  6. リポジトリは“集約単位”で扱う
  7. アプリケーション層でユースケースを組み立てる

1. ユビキタス言語:クラス設計より先に「名詞・動詞・制約」を書く

中級者がハマりやすいのは、いきなりエンティティを作り始めることです
先にやるのは、以下の3点の棚卸し

  • 名詞:顧客、注文、注文明細、商品、支払い、クーポン…
  • 動詞:注文する、支払う、キャンセルする、適用する…
  • 制約:支払い前のみキャンセル可能/クーポンは注文合計が○円以上で適用…

この「制約」こそがドメインモデルに閉じ込めるべきものです


2. 不変条件(Invariant)を列挙する:設計の軸はここ

ドメインモデル設計の中心は不変条件です。
例(注文ドメイン):

  • 注文は 支払い完了後 に注文明細を変更できない
  • 注文合計は「明細の合計」から必ず計算される(手入力させない)
  • キャンセルは「発送前」のみ可能
  • クーポン適用には下限金額がある

ここまで出たら、次が集約設計です


3. 集約(Aggregate)設計:「一緒に守るべき不変条件」で境界を切る

集約は「関連があるから一緒」ではなく、同時に整合性を保ちたいルールがあるから一緒です

集約を切る基準(実務で効くやつ)

  • 同一トランザクションで必ず整合性が必要? → Yesなら同一集約候補
  • 外部から参照される入口は1つにできる? → 集約ルート(Aggregate Root)
  • 大きすぎてロックや性能が厳しい? → 分割 or 結果整合へ

たとえば「注文」と「支払い」は密に関係しますが、必ずしも同一集約にする必要はありません
支払いが外部決済で非同期なら、注文集約とは別にして 結果整合(イベント/ステータス遷移) にする方が安全なこともあります


4. 値オブジェクト(Value Object)で「型」にルールを持たせる

文字列やintで表すほど、バグが混入します

悪い例

  • String email
  • int amount
  • String orderId

良い例

  • Email
  • Money
  • OrderId

値オブジェクトは以下を徹底すると効果が大きいです

  • 不変(immutable)
  • 生成時にバリデーション
  • equals/hashCodeは値で比較

Java例:値オブジェクト(Email / Money)

import java.util.Objects;

public final class Email {
    private final String value;

    private Email(String value) {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("invalid email");
        }
        this.value = value;
    }

    public static Email of(String value) {
        return new Email(value);
    }

    public String value() { return value; }

    @Override public boolean equals(Object o) {
        return (o instanceof Email other) && Objects.equals(value, other.value);
    }
    @Override public int hashCode() { return Objects.hash(value); }
}
import java.math.BigDecimal;
import java.util.Objects;

public final class Money {
    private final BigDecimal amount;

    private Money(BigDecimal amount) {
        if (amount == null || amount.signum() < 0) throw new IllegalArgumentException("amount must be >= 0");
        this.amount = amount;
    }

    public static Money of(BigDecimal amount) { return new Money(amount); }

    public Money add(Money other) { return new Money(this.amount.add(other.amount)); }

    public BigDecimal amount() { return amount; }

    @Override public boolean equals(Object o) {
        return (o instanceof Money other) && amount.compareTo(other.amount) == 0;
    }
    @Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros()); }
}

5. エンティティ:IDとライフサイクルを持つ「同一性のあるもの」だけにする

エンティティは増やしすぎると設計が崩れます
「同一性が必要」なものに限定するのがコツです

  • 注文(Order)
    • IDがあり状態遷移がある → エンティティ
  • 注文明細(OrderLine)
    • 集約内の構成要素だが同一性が必要ならエンティティ、不要なら値オブジェクトでもよい

6. 集約ルートに「振る舞い」を集める:貧血モデルを避ける書き方

よくある失敗は、アプリケーション層で if 文だらけにして、エンティティは getter/setter だけ

代わりに、不変条件を守る操作は集約ルートのメソッドとして提供します

Java例:Order集約(ルールを中に閉じ込める)

public class Order {
    private final OrderId id;
    private OrderStatus status;
    private final List<OrderLine> lines = new ArrayList<>();

    public Order(OrderId id) {
        this.id = id;
        this.status = OrderStatus.DRAFT;
    }

    public void addLine(OrderLine line) {
        if (status != OrderStatus.DRAFT) {
            throw new DomainException("cannot modify after confirmed");
        }
        lines.add(line);
    }

    public void confirm() {
        if (lines.isEmpty()) throw new DomainException("order must have at least one line");
        status = OrderStatus.CONFIRMED;
    }

    public Money totalPrice() {
        Money total = Money.of(java.math.BigDecimal.ZERO);
        for (OrderLine line : lines) total = total.add(line.subtotal());
        return total;
    }
}

ポイントは「状態を直接 set させない」こと
外からの変更入口を制限するのが集約の価値です


7. ドメインサービス:エンティティ/値オブジェクトに置けない「ドメインの計算」だけ置く

ドメインサービスを多用すると手続き型に戻ります
出番はこの条件のときだけ

  • 複数集約にまたがる計算・判断
  • 主語が“モノ”ではなく「ルール」になる
  • エンティティに入れると責務が不自然

例:送料計算、与信判定、割引最適化など


8. リポジトリ:集約ルート単位で永続化する

DDDのリポジトリは「テーブルのDAO」ではなく、集約の出し入れ口です

  • OrderRepository#findById(OrderId id)
  • OrderRepository#save(Order order)

注文明細だけを個別に更新する、のような入口を増やすと集約が壊れやすくなります


9. アプリケーション層(ユースケース層):トランザクションを組み立てるだけ

アプリ層の役割は次です

  • リポジトリから集約を取得
  • 集約のメソッドを呼ぶ
  • 保存する
  • 外部I/O(決済、通知)を呼ぶ(必要ならドメインイベント)

判断ロジックをアプリ層に散らさないのが設計のコツです


10. 「こう設計する」チェックリスト(ここが実務で効く)

最後に、ドメインモデルをレビューする際のチェック例を紹介します

集約

  • 集約内の不変条件が集約ルートのメソッドで守られている
  • 集約外から内部エンティティを直接更新できない
  • リポジトリは集約ルート単位になっている

値オブジェクト

  • 文字列/intで表している概念に、VO化できるものが残っていない(Email, Money, Idなど)
  • VOは不変で、生成時バリデーションがある

レイヤリング

  • ビジネス判断がアプリ層のif文に散っていない
  • ドメインサービスは“本当に必要なときだけ”になっている

11. まとめ

よくあるアンチパターン

  • 集約が巨大化して全部を1トランザクションに詰め込む
  • Entity = DBテーブルで設計してしまう(ドメインの制約が薄まる)
  • VOを作らずString地獄(バリデーションが分散)
  • アプリ層if文地獄(貧血モデル化)

設計順を守ると迷いが減る

ドメインモデル設計は、結局のところ

  1. 不変条件を列挙し
  2. それを守る単位で集約を切り
  3. 値オブジェクトで型にルールを寄せ
  4. 集約ルートに振る舞いを集める

これだけで再現性が上がります


ぜひご参考ください!

是非フォローしてください

最新の情報をお伝えします