- 技術 💻
ドメイン駆動設計入門|DDDの基本と小さく始める手順をJavaで解説
ドメイン駆動設計(DDD:Domain-Driven Design)とは、ソフトウェアが対象とする業務(ドメイン)の知識と言葉を中心に置き、その知識をそのままコードで表現していく設計の考え方です。2003年にEric Evansが著書『Domain-Driven Design』で提唱しました。
DDDは「値オブジェクト」「集約」などの実装パターンが目立つため、「特定のフォルダ構成やレイヤー構成のこと」と誤解されがちです。しかし本来の中心は、業務の専門家と開発者が同じ言葉で話し、その言葉と境界をコードに反映することにあります。この記事では、DDDの全体像を戦略的設計(境界と言葉を決める)と戦術的設計(コードで表す)に分けて整理し、値オブジェクトと集約はJava 21で実際に動かした結果で、「直す前と後」を比べます。最後に、既存のプロジェクトで小さく始める手順を紹介します。
ドメイン駆動設計の用語早見表
| 分類 | 用語 | ひとことで |
|---|---|---|
| 戦略的設計 | ユビキタス言語 | 業務の専門家と開発者が共通で使い、コードにもそのまま使う言葉 |
| 戦略的設計 | 境界づけられたコンテキスト | 1つの言葉が1つの意味を持つ範囲。モデルとチームの境界 |
| 戦略的設計 | コンテキストマップ | コンテキスト同士の関係(どちらが上流か、どう変換するか)の地図 |
| 戦略的設計 | コアドメイン | 事業の強みになる、最も力を入れるべき領域 |
| 戦術的設計 | エンティティ | IDで区別され、状態が変わっても同じものとして扱う(例:会員、注文) |
| 戦術的設計 | 値オブジェクト | 値そのもので区別され、変更できない(例:金額、期間、住所) |
| 戦術的設計 | 集約 | 一緒に整合性を保つオブジェクトのまとまり。変更は入口(集約ルート)を通す |
| 戦術的設計 | リポジトリ | 集約を保存・取得する窓口。DBの詳細を隠す |
| 戦術的設計 | ドメインサービス | どのエンティティにも自然に属さない業務ルール |
| 戦術的設計 | ドメインイベント | 業務上の重要な出来事(例:注文が確定した) |
| 戦術的設計 | ファクトリ | 複雑な集約の生成をまとめる |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ドメイン駆動設計入門|DDDの基本と小さく始める手順をJavaで解説」
DDDの目的:複雑な業務を、変更に強いコードで表す
Evansの原著の副題は「ソフトウェアの核心にある複雑さに立ち向かう」です。DDDが解決しようとしているのは、技術の複雑さではなく、業務そのものの複雑さです。
| DDDを使わないときに起きがちなこと | DDDで目指す状態 |
|---|---|
業務の担当者は「受注」、仕様書は「オーダー」、コードは Request と、同じものの呼び名がばらばら |
同じ言葉が、会話・仕様・コードで一致している |
| 業務のルールが、画面・サービス・SQLのあちこちに散らばる | ルールがドメインのオブジェクトに集まり、1か所を見ればわかる |
1つの巨大なモデル(例:すべての部署の都合が入った Customer クラス)を全員で共有し、変更の影響が読めない |
境界ごとにモデルを分け、変更の影響を境界の中に閉じ込める |
戦略的設計:言葉と境界を決める
DDDで最初に取り組むべきなのは、コードの書き方より、言葉と境界を決める「戦略的設計」です。
ユビキタス言語
ユビキタス言語とは、業務の専門家と開発者がチーム内で共通に使い、コードのクラス名やメソッド名にもそのまま使う言葉です。
| 会話で使う言葉 | ユビキタス言語がないコード | ユビキタス言語を反映したコード |
|---|---|---|
| 「注文を確定する」 | order.setStatus(2) |
order.confirm() |
| 「在庫を引き当てる」 | stockDao.update(id, qty * -1) |
stock.allocate(quantity) |
| 「会員ランクが上がる」 | user.setLevel(user.getLevel() + 1) |
member.promote() |
会話に出てこない言葉がコードにあったり、コードの名前が会話で通じなかったりしたら、言葉を見直すサインです。用語集を作り、仕様の議論で新しい言葉が出たら、コードと一緒に更新します。
境界づけられたコンテキスト
境界づけられたコンテキスト(Bounded Context)とは、1つのモデルと言葉が、一貫した意味を持つ範囲のことです。同じ「商品」という言葉でも、部署によって関心は違います。
| コンテキスト | 「商品」の意味 | 必要な情報 |
|---|---|---|
| カタログ | お客さまに見せるもの | 名前、説明、画像、表示価格 |
| 在庫 | 倉庫で数えるもの | 保管場所、数量、引当済みの数 |
| 配送 | 運ぶもの | 重さ、サイズ、配送区分 |
これを1つの Product クラスにまとめると、どの部署の変更でも同じクラスを触ることになり、影響範囲が読めなくなります。コンテキストごとに別のモデルを持ち、共通なのは商品ID程度にするのがDDDの考え方です。境界は、チームの分け方や、マイクロサービスの分け方を考えるときの有力な手がかりにもなります。
コンテキストマップとコアドメイン
コンテキストマップは、コンテキスト同士の関係を描いた地図です。代表的な関係には、相手のモデルに合わせる「順応者(Conformist)」、変換層を置いて自分のモデルを守る「腐敗防止層(Anti-Corruption Layer)」などがあります。
また、ドメインはコアドメイン(事業の強み)、支援サブドメイン(必要だが差別化にならない)、汎用サブドメイン(認証・メール配信など、既製品で済むもの)に分けて考えます。DDDの手間をかけるのはコアドメインに絞り、それ以外は既製品やシンプルな作りで済ませるのが、現実的な進め方です。
戦術的設計:業務の知識をコードで表す
エンティティと値オブジェクト
| エンティティ | 値オブジェクト | |
|---|---|---|
| 区別の方法 | ID(中身が同じでも、IDが違えば別物) | 値(値が同じなら同じもの) |
| 変更 | 状態が変わる(会員の住所変更など) | 変更できない。変えるときは新しい値を作る |
| 例 | 会員、注文、商品 | 金額、期間、住所、メールアドレス、数量 |
| Javaでの書き方 | 通常のクラス。equals はIDで比較 |
record が向いている |
実務で最初に効果が出やすいのは値オブジェクトです。long や String のままだと、「マイナスの金額」「通貨の違う金額の足し算」を防げません。
// 値オブジェクト:金額と通貨をセットで扱い、不正な値を作れないようにする
record Money(long amount, String currency) {
Money {
if (amount < 0) throw new IllegalArgumentException("金額は0以上: " + amount);
Objects.requireNonNull(currency);
}
static Money yen(long amount) { return new Money(amount, "JPY"); }
Money add(Money other) {
if (!currency.equals(other.currency))
throw new IllegalArgumentException(currency + " と " + other.currency + " は足せません");
return new Money(amount + other.amount, currency);
}
Money times(int n) { return new Money(amount * n, currency); }
boolean isGreaterThan(Money other) { return amount > other.amount; }
@Override public String toString() { return amount + " " + currency; }
}
Money a = Money.yen(1000);
Money b = Money.yen(1000);
System.out.println("値が同じなら等しい: " + a.equals(b));
System.out.println("足し算は新しい値を返す: " + a.add(Money.yen(500)) + " / 元の値: " + a);
try { Money.yen(-1); }
catch (IllegalArgumentException e) { System.out.println("生成時に検証: " + e.getMessage()); }
try { a.add(new Money(10, "USD")); }
catch (IllegalArgumentException e) { System.out.println("通貨違いの計算を防ぐ: " + e.getMessage()); }
値が同じなら等しい: true
足し算は新しい値を返す: 1500 JPY / 元の値: 1000 JPY
生成時に検証: 金額は0以上: -1
通貨違いの計算を防ぐ: JPY と USD は足せません
Money 型の値は、必ず検証を通った正しい値です。受け取る側で毎回チェックする必要がなくなり、メソッドの引数が long price ではなく Money price になることで、意味の取り違えもコンパイル時に防げます。
集約と集約ルート
集約とは、一緒に整合性(業務のルール)を守らなければならないオブジェクトのまとまりです。外から変更するときは、必ず入口である「集約ルート」のメソッドを通します。
直す前:データの入れ物だけのモデル
class Order { // データの入れ物だけ(ルールは外のサービスに散らばる)
public List<OrderLine> lines = new ArrayList<>();
public String status = "DRAFT";
public long total;
}
record OrderLine(String product, long unitPrice, int quantity) {}
Order order = new Order();
order.lines.add(new OrderLine("ノート", 300, 2));
order.status = "CONFIRMED"; // 確定済み
// 別の画面の処理:確定後なのに明細を直接追加できてしまう
order.lines.add(new OrderLine("ペン", 120, 1000));
// 合計の再計算も呼び忘れた
System.out.println("状態=" + order.status + ", 明細数=" + order.lines.size() + ", 合計=" + order.total);
状態=CONFIRMED, 明細数=2, 合計=0
確定済みの注文に明細が追加され、合計も0円のままです。「確定後は変更できない」「合計は明細から計算する」というルールが、Order の外(各サービス)に任されているため、1か所でも守り忘れると、ありえない状態のデータが生まれます。このように、データだけを持ちルールを持たないモデルは「ドメインモデル貧血症」と呼ばれます。
直した後:ルールを集約ルートに集める
record OrderLine(String product, Money unitPrice, int quantity) {
Money subtotal() { return unitPrice.times(quantity); }
}
record OrderConfirmed(OrderId orderId, Money total) {} // ドメインイベント
// 集約ルート:明細の追加・確定はすべてここを通す
class Order {
private static final Money LIMIT = Money.yen(30_000); // 1注文の上限
private final OrderId id;
private final List<OrderLine> lines = new ArrayList<>();
private boolean confirmed = false;
private final List<Object> events = new ArrayList<>();
private Order(OrderId id) { this.id = id; }
static Order create(OrderId id) { return new Order(id); }
void addLine(String product, Money unitPrice, int quantity) {
if (confirmed) throw new IllegalStateException("確定済みの注文は変更できません");
var line = new OrderLine(product, unitPrice, quantity);
if (total().add(line.subtotal()).isGreaterThan(LIMIT))
throw new IllegalStateException("1注文の上限 " + LIMIT + " を超えます");
lines.add(line);
}
void confirm() {
if (lines.isEmpty()) throw new IllegalStateException("明細がありません");
confirmed = true;
events.add(new OrderConfirmed(id, total()));
}
Money total() { return lines.stream().map(OrderLine::subtotal).reduce(Money.yen(0), Money::add); }
List<OrderLine> lines() { return List.copyOf(lines); } // 読み取り専用のコピーを返す
OrderId id() { return id; }
List<Object> pullEvents() { var e = List.copyOf(events); events.clear(); return e; }
}
// リポジトリ:集約単位で保存・取得する(インターフェースはドメイン側に置く)
interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
動かしてみます(リポジトリは、テスト用にメモリ上に保存する実装を使用)。
Order order = Order.create(new OrderId("ORD-1"));
order.addLine("ノート", Money.yen(300), 2);
order.addLine("ペン", Money.yen(120), 3);
System.out.println("合計=" + order.total() + ", 明細数=" + order.lines().size());
try { order.addLine("ファイル", Money.yen(5000), 10); }
catch (IllegalStateException e) { System.out.println("ルール違反: " + e.getMessage()); }
order.confirm();
repo.save(order);
System.out.println("発生したイベント: " + order.pullEvents());
Order loaded = repo.findById(new OrderId("ORD-1")).orElseThrow();
try { loaded.addLine("消しゴム", Money.yen(100), 1); }
catch (IllegalStateException e) { System.out.println("ルール違反: " + e.getMessage()); }
try { loaded.lines().add(new OrderLine("のり", Money.yen(150), 1)); }
catch (UnsupportedOperationException e) { System.out.println("外から明細は変更できない: " + e); }
合計=960 JPY, 明細数=2
ルール違反: 1注文の上限 30000 JPY を超えます
発生したイベント: [OrderConfirmed[orderId=OrderId[value=ORD-1], total=960 JPY]]
ルール違反: 確定済みの注文は変更できません
外から明細は変更できない: java.lang.UnsupportedOperationException
「確定後は変更不可」「上限を超えない」「合計は常に明細と一致」というルールが、Order の中だけで守られています。どの画面・どのAPIから使っても、ルールを破る方法がありません。
集約を設計するときの目安
- 集約は小さく保つ。「一緒に変わらなければならないもの」だけを入れる
- 他の集約は、オブジェクトではなくIDで参照する(
OrderはMemberではなくMemberIdを持つ) - 1つのトランザクションでは、原則1つの集約だけを更新する。複数の集約にまたがる処理は、ドメインイベントなどでつなぐ
リポジトリ
リポジトリは、集約を保存・取得するための窓口です。インターフェースはドメイン層に置き、JPAやSQLを使った実装はインフラ層に置きます(依存性逆転の原則)。テーブルごとではなく、集約ごとに1つ作るのがポイントで、上の例では OrderLine 用のリポジトリは作りません。
ドメインサービス
どのエンティティや値オブジェクトに入れても不自然な業務ルールは、ドメインサービスに置きます。たとえば「2つの倉庫の間で在庫を移す」「会員IDが重複していないか確認する」などです。
ただし、何でもサービスに書くと、結局ルールがエンティティの外に散らばるため、まずはエンティティや値オブジェクトに置けないかを考え、それでも自然に置けないものだけをドメインサービスにします。
ドメインイベント
ドメインイベントは、「注文が確定した」のような、業務の専門家が関心を持つ出来事を表すオブジェクトです。名前は過去形にします。上の例の OrderConfirmed がこれにあたり、「確定したら確認メールを送る」「在庫を引き当てる」といった後続の処理を、注文の集約から切り離してつなげられます。なお、ドメインイベントはEvansの原著(2003年)の出版後に広まった考え方で、2015年の『Domain-Driven Design Reference』で主要なパターンとして整理されました。
レイヤー構成:ドメイン層を中心に置く
DDDでは、業務のルールを書くドメイン層を、技術的な詳細から独立させることを重視します。
| 層 | 役割 | この記事の例 |
|---|---|---|
| プレゼンテーション層 | 画面・APIの入出力 | OrderController |
| アプリケーション層 | ユースケースの手順を組み立てる(取得→ドメインの操作→保存)。業務ルールは書かない | ConfirmOrderService |
| ドメイン層 | 業務のルールそのもの | Order、Money、OrderRepository(インターフェース) |
| インフラ層 | DB・外部APIなど技術的な実装 | JpaOrderRepository |
依存の向きは、外側から内側(ドメイン層)へそろえます。ドメイン層は、SpringやJPAを知らなくても動く状態が理想です(上の Order はフレームワークなしで動いています)。これを徹底した構成が、オニオンアーキテクチャ、ヘキサゴナルアーキテクチャ(ポートとアダプター)、クリーンアーキテクチャです。
よくある誤解
| 誤解 | 実際 |
|---|---|
| DDD=レイヤードアーキテクチャ(やフォルダ構成) | レイヤーはドメインを守るための手段の1つ。DDDの中心は、言葉と境界とモデル |
| DDD=値オブジェクトやリポジトリを使うこと | 戦術的なパターンだけ真似る「軽量DDD」も役立つが、言葉と境界を決めないと効果は限られる |
| すべての領域にDDDを適用する | 手間をかけるのはコアドメイン。単純な管理画面はシンプルな作りでよい |
| マイクロサービスにしないとDDDではない | 1つのアプリケーションの中でも、モジュール(パッケージ)で境界を分ければ実践できる |
| 最初に完璧なモデルを作る | モデルは、業務の専門家との会話と実装をくり返して少しずつ深めていく |
| エンティティ=DBのテーブル | エンティティは業務上の概念。1つの集約が複数のテーブルに保存されることもある |
向いているプロジェクト・向いていないプロジェクト
| 向いている | 向いていない(別の方法で十分) |
|---|---|
| 業務のルールが複雑で、事業の強みに直結する | 画面からDBへの登録・参照(CRUD)が中心 |
| 長期間、機能追加が続く | 短期間で使い終わる、試作品 |
| 業務の専門家と継続的に話せる | 業務の専門家に話を聞けない |
| 複数のチームで、境界を分けて開発したい | 1人・短期間の小さな開発 |
「DDDを採用するか、しないか」の二択ではなく、コアドメインの一部から取り入れることができます。
既存のプロジェクトで小さく始める手順
- 用語集を作る:業務の担当者との会話で出る言葉と、コードの名前の対応表を作る。ずれている名前を見つける
- 値オブジェクトを1つ導入する:金額、期間、数量など、チェックが各所に散らばっている値を
recordにする。効果が見えやすく、既存コードへの影響も小さい - ルールを1つ、エンティティに移す:サービスにある「確定後は変更不可」のようなチェックを、
order.confirm()のようなメソッドに移し、setterを減らす - 集約の境界を決める:一緒に整合性を守るべき範囲を決め、外からは集約ルートのメソッドだけで変更するようにする
- リポジトリのインターフェースをドメイン側に置く:ドメインのテストを、DBなしで書けるようにする
- 境界づけられたコンテキストを見直す:同じ言葉が別の意味で使われている場所を見つけ、パッケージ(モジュール)を分ける
各ステップで、ドメインのテスト(DBもフレームワークも使わないテスト)を書くと、効果を確認しながら進められます。
学ぶための書籍
| 書籍 | 特徴 |
|---|---|
| エリック・エヴァンス『エリック・エヴァンスのドメイン駆動設計』(今関剛 監訳、和智右桂・牧野祐子 訳、翔泳社) | DDDの原典。戦略的設計まで含めた全体像 |
| ヴォーン・ヴァーノン『実践ドメイン駆動設計』(髙木正弘 訳、翔泳社) | 原典の考え方を実装にどう落とすかを詳しく解説 |
| 成瀬允宣『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』(翔泳社) | 値オブジェクトなどの実装パターンから段階的に学べる入門書 |
| Vlad Khononov『ドメイン駆動設計をはじめよう』(増田亨・綿引琢磨 訳、オライリー・ジャパン) | 事業戦略と戦略的設計の結びつきをわかりやすく解説 |
入門の順番としては、実装パターンから入るなら成瀬さんの本、戦略的設計から入るならKhononovの本を読み、その後に原典に進むと理解しやすくなります。
よくある質問
Q. DDDはJavaでしか使えませんか?
A. 言語は問いません。C#、Kotlin、TypeScript、Go、PHPなどでも実践されています。ただし、値オブジェクトを record で簡潔に書け、型でルールを表しやすいJavaやKotlinは、戦術的設計と相性が良い言語です。
Q. JPAのエンティティと、DDDのエンティティは同じにしてよいですか? A. 小規模なら、JPAのアノテーションをドメインのエンティティに直接付ける方法も現実的です。ただし、DBの都合(setterや引数なしコンストラクター)がドメインのルールを弱めやすいため、ルールが複雑な集約では、ドメインのモデルと永続化用のクラスを分け、リポジトリの実装で変換する方法がよく選ばれます。
Q. DDDとクリーンアーキテクチャの違いは何ですか? A. DDDは業務をどうモデル化するか(言葉・境界・モデル)の考え方、クリーンアーキテクチャはコードの依存関係をどう整理するかの考え方です。目的が近く、組み合わせて使われることが多いですが、別のものです。
Q. AIでコードを生成する時代に、DDDは役立ちますか? A. 役立ちます。ユビキタス言語の用語集や集約のルールを文章で渡すと、AIが生成するコードの名前や責任の分け方がそろいやすくなります。業務の言葉とコードが一致していると、生成されたコードのレビューもしやすくなります。
まとめ
- DDDは、業務の知識と言葉を中心に置き、それをコードで表現する設計の考え方(Eric Evans、2003年)
- まず戦略的設計:ユビキタス言語で言葉をそろえ、境界づけられたコンテキストでモデルを分ける
- 次に戦術的設計:値オブジェクトで不正な値を作れなくし、集約でルールを1か所に集める
- 集約は小さく、他の集約はIDで参照、1トランザクション1集約が目安
- DDD=レイヤー構成ではない。レイヤーはドメインを守るための手段
- 手間をかけるのはコアドメイン。CRUD中心の領域はシンプルでよい
- 用語集と値オブジェクト1つから、小さく始められる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ドメイン駆動設計入門|DDDの基本と小さく始める手順をJavaで解説」
株式会社RJCでは、業務を深く理解し、それを読みやすいコードで表現することを目指して、設計の考え方をプロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。