ドメイン駆動設計(DDD)に興味はあるものの、分厚い本や独特な用語に尻込みをしてしまった... そんな方はいませんか?
私は、「ユビキタス言語」や「境界づけられたコンテキスト」といった難解な用語に苦戦し、やる気が削がれてしまいました。手を動かさないと頭に入らないタイプなので、実際にコードを書きながらDDDの基本的な概念を学ぶことにしました。
はじめに
記事の概要
Spring Bootで作成されたシンプルなクイズアプリケーションのバックエンドREST APIを題材に、3層アーキテクチャからDDDを意識したアーキテクチャへリファクタリング を行い、それぞれのアーキテクチャを比較しながら、DDDの目的と実装方法をまとめました。
本記事の対象範囲
ドメイン駆動設計は 戦略的設計 と 戦術的設計 に大別されます。早速難しい言葉が登場しましたが、このセクション以外では使いません。
一般に「戦略」とは進むべき方向性や中長期的な計画を指し、「戦術」とは戦略を実現するための具体的な手段を指しますが、DDDにおいても同様です。
戦略的設計 はシステム化の対象であるドメインを分割し、境界づけるモデリングに関する考え方や手法を扱います。一方 戦術的設計 ではモデリングしたドメインをコードに落とし込むためのパターンや手法を扱います。本記事では 戦術的設計 に焦点を当て、DDDの基本的な考え方を実装を通じて学びます。
DDDの戦術的設計だけを取り入れるのは「軽量DDD」と呼ばれアンチパターンとされることもありますが、手を動かさないと頭に入らないタイプにとっては、まずは実装を通じてDDDに慣れていくことが近道だと思います。
アーキテクチャの比較
一般の3層アーキテクチャ
まずはSpring Bootに限定されない一般的な3層アーキテクチャについて、それぞれ以下の役割を持ちます。
- プレゼンテーション層:ユーザーとの入出力(画面・API)を扱う
- ビジネスロジック層:ビジネスロジックを扱う
- データアクセス層:データベースや外部システムへの読み書きを扱う
Spring Bootにおける3層アーキテクチャ
Spring Bootでの3層アーキテクチャは以下のようになります。以降、本記事で「3層アーキテクチャ」と言う場合はこの構成を指します。
DDDにおけるアーキテクチャ
DDDを実現するための最も単純なアーキテクチャの例を示します。DDD自体は設計手法なので特定のアーキテクチャを強制するものではありません。以降、本記事で「DDDアーキテクチャ」と言う場合はこの構成を指します。
3層アーキテクチャとDDDアーキテクチャを比較したとき、変化しているのは以下の2点です。
- ビジネスロジック層とデータアクセス層の依存関係が逆転している
- ビジネスロジック層がApplication層とDomain層に分割されている
アーキテクチャの違いから見るDDDの目的
DDDを取り入れるために行われた2つの変更はどちらも Domain層を独立させる ためです。
1. 依存関係の逆転
データアクセス層がビジネスロジック層に依存する、という依存関係の逆転の目的はビジネスロジックを技術的関心から分離すること にあります。
データの保存方法にはRDB、NoSQL、ファイルストレージなど様々な選択肢がありますが、これらを「技術的関心」と呼びます。ビジネスロジック層がデータアクセス層に依存しなくなることで、データの保存方法が変わってもビジネスロジックの実装は変更せずに済みます。
依存関係の逆転は、データアクセス層の実装形式をビジネスロジック層がインターフェースとして提供することで実現されます。インターフェースには What(何をするか)だけが定義され、How(どのようにするか)はデータアクセス層に委ねられます。
例として、運転方法(ビジネスロジック)と自動車(技術的関心)の関係で考えてみます。車種やエンジン方式が変わっても、「右がアクセル、左がブレーキ」という運転ルールは変わりません。車は決まった運転方法に合わせて作られているからです。
DDDも同様に、ドメイン(ビジネスのルール)が中心にあり、DBや通信方式といった技術はドメインを実現する手段として後から選ばれます。
2. ビジネスロジック層の分割
ビジネスロジック層をDomain層とApplication層に分割する目的はビジネスロジック(ビジネスルール)とユースケースの分離にあります。
Domain層はビジネスロジックやルールを表現し、Application層はビジネスロジック、ルールを用いてユースケースを組み立てます。分離により、ユースケースの変更がドメインに影響を与えにくくなり、Domain層の独立性が保たれます。
例として、ある動画について「20歳未満は視聴できない」というルールがあるとします。このルール(Domain層)と、「ストリーミング再生」「ダウンロード」という視聴方法(Application層)を分離します。これにより、新しい視聴方法が追加されても、年齢判定ルールは変わりません。
課題設定:クイズアプリケーションのバックエンド
ここから内容が段々と実装に近づいていくので、具体的な実装課題を設定します。課題はクイズアプリケーションのバックエンドREST APIです。
アプリケーション概要
- カテゴリー(例:数学、歴史、科学など)を選択する
- 選択したカテゴリーに対してチャレンジを開始する
- 選択したカテゴリーに属するチャレンジ内で未回答のクイズをランダムに1問取得する
- クイズに対する選択肢を1つ選んで回答する
- サーバーから回答の正誤を受け取る
- カテゴリー内のクイズを全て回答すると、チャレンジが完了する
(未回答のクイズがなくなるまで3-5を繰り返す)
簡単化のためユーザー管理機能は実装せず、チャレンジはクライアント単位で管理する
API仕様
-
GET /api/categories:カテゴリー一覧を取得する -
POST /api/challenges: 指定したカテゴリーに対するチャレンジを開始する -
GET /api/challenges/{challengeId}/quiz: 指定したチャレンジ内で未回答のクイズをランダムに1問取得する -
POST /api/challenges/{challengeId}/answers: クイズに対する回答を送信し、正誤を受け取る
ER図
テーブル説明
-
category:クイズのカテゴリー -
quiz:クイズの問題文、1つのカテゴリーに複数のクイズが属する -
choice:クイズの選択肢、1つのクイズに複数の選択肢が属し、正解かどうかを保持する -
challenge:カテゴリーに対する1回のチャレンジ、カテゴリー内の全てのクイズを回答した時点でcompleted_atが設定されチャレンジは完了となる -
answer:チャレンジ中に行われたクイズへの回答、回答時点での正誤結果を保持する
ディレクトリ構成とデータフロー
3層アーキテクチャとDDDアーキテクチャでのディレクトリ構成とデータフローの違いを見ていきます。ここで挙げるのはあくまで一例であり、アーキテクチャごとに厳密に決まっているわけではありません。
3層アーキテクチャ
ディレクトリ構成
demo
├── controller
│ ├── CategoryController.java
│ └── ChallengeController.java
├── service
│ ├── CategoryService.java
│ └── ChallengeService.java
├── repository
│ ├── CategoryRepository.java
│ ├── QuizRepository.java
│ ├── ChoiceRepository.java
│ ├── ChallengeRepository.java
│ └── AnswerRepository.java
├── dto
│ ├── request
│ │ ├── ChallengeRequest.java
│ │ └── AnswerRequest.java
│ └── response
│ ├── CategoryResponse.java
│ ├── ChallengeResponse.java
│ ├── QuizResponse.java
│ └── AnswerResponse.java
├── entity
│ ├── CategoryEntity.java
│ ├── QuizEntity.java
│ ├── ChoiceEntity.java
│ ├── ChallengeEntity.java
│ └── AnswerEntity.java
└── DemoApplication.java
dto/request内のクラスはAPIのリクエストボディの形式、dto/response内のクラスはレスポンスボディの形式を表現します。
entity/内のクラスはデータベースのテーブルに一対一に対応します。データベースのレコードはrepository/内でORMを用いてentity/のそれぞれのクラスにマッピングされます。
データフロー
Service層で Request/Response DTO と Entity の相互の変換を行います。
DDDアーキテクチャ
ディレクトリ構成
提供する機能は同じなのにかなりファイル数が増えました。ここから分かるようにDDDは実装コストが高いですが、長期的な保守性や拡張性は向上します。目的を考えて適材適所でDDDの考え方を取り入れていくことが重要です。
demo
├── presentation
│ ├── CategoryController.java
│ └── ChallengeController.java
├── application
│ ├── CategoryService.java
│ └── ChallengeService.java
├── domain
│ ├── category
│ │ ├── CategoryId.java # 値オブジェクト
│ │ ├── CategoryName.java # 値オブジェクト
│ │ ├── Category.java # エンティティ(集約ルート)
│ │ └── CategoryRepository.java # リポジトリインターフェース
│ ├── quiz
│ │ ├── QuizId.java # 値オブジェクト
│ │ ├── QuizText.java # 値オブジェクト
│ │ ├── Quiz.java # エンティティ(集約ルート)
│ │ ├── QuizRepository.java # リポジトリインターフェース
│ │ ├── ChoiceId.java # 値オブジェクト
│ │ ├── ChoiceText.java # 値オブジェクト
│ │ └── Choice.java # エンティティ
│ └── challenge
│ ├── ChallengeId.java # 値オブジェクト
│ ├── Challenge.java # エンティティ(集約ルート)
│ ├── ChallengeRepository.java # リポジトリインターフェース
│ ├── AnswerId.java # 値オブジェクト
│ └── Answer.java # エンティティ
├── infrastructure
│ ├── repository
│ │ ├── CategoryRepositoryImpl.java # リポジトリ実装
│ │ ├── QuizRepositoryImpl.java # リポジトリ実装
│ │ ├── ChoiceRepositoryImpl.java # リポジトリ実装
│ │ ├── ChallengeRepositoryImpl.java # リポジトリ実装
│ │ └── AnswerRepositoryImpl.java # リポジトリ実装
│ └── entity
│ ├── CategoryEntity.java
│ ├── QuizEntity.java
│ ├── ChoiceEntity.java
│ ├── ChallengeEntity.java
│ └── AnswerEntity.java
├── dto
│ ├── request
│ │ ├── ChallengeRequest.java
│ │ └── AnswerRequest.java
│ └── response
│ ├── CategoryResponse.java
│ ├── ChallengeResponse.java
│ ├── QuizResponse.java
│ └── AnswerResponse.java
└── DemoApplication.java
dto/、infrastructure/entity/(3層アーキテクチャのentity/に相当)、presentation/(3層アーキテクチャの controller/に相当) は3層アーキテクチャと同様です。
/infrastructure/entityと/domainのエンティティは別物です。
余談:Spring Bootのリファレンスアーキテクチャ
Spring Bootのリファレンスのアーキテクチャの変遷についての記事が面白かったので、興味がある方はぜひ読んでみてください。Spring Bootのサンプルパッケージ構成の考古学
最近はDDDの流れで package by feature が普及してきているそうですが、個人的には「ドメインを独立させる」というDDDの目的を考えるなら package by layer の方がしっくりきます。
データフロー
Domain Model は domain/ 内のエンティティを指します。
Application層で Request/Response DTO と Domain Model の相互の変換、Infrastructure層で Domain Model と Entity の相互の変換を行います。
DDDを実現するための手法
実装に入る前にDDDを実現するための基本的な手法を説明します。
依存関係の逆転
データアクセス層のインターフェースをドメイン層に定義し、データアクセス層の実装はインターフェースを実装することで依存関係の逆転を実現します。
DDDアーキテクチャにおけるディレクトリ構成の domain/*/Repository.java がリポジトリのインターフェース、infrastructure/repository/*RepositoryImpl.java がリポジトリの実装に相当します。
値オブジェクト
値オブジェクトは、特定の「値」とその意味・制約をひとまとまりにし、より厳密な型として扱うためのオブジェクトです。
主なメリットは次の2つです。
- 型安全性の向上:意味の異なる値の取り違えを、コンパイル時に検出できる
- 制約の一元化:値に固有のルールをクラス内に閉じ込め、不正な値の生成を防げる
例として「年齢」と「温度」を考えます。
どちらも整数で表現できますが、引数に int のようなプリミティブ型を使うと、年齢と温度を誤って入れ替えて渡してもコンパイル時には気づけません。そこで、年齢を Age、温度を Temperature として値オブジェクト化すると、両者は別の型になるため、取り違えはコンパイル時にエラーとして検出されます。
また、年齢は「0以上」、温度は「絶対零度以上」など、それぞれ固有の制約を持ちます。この制約を値オブジェクト内で定義しておけば、制約に違反した不正な値が入力されるのを防ぐことができます。
年齢の値オブジェクト
public final class Age {
private final int value;
public Age(int value) {
if (value < 0) {
throw new IllegalArgumentException("年齢は0以上である必要があります");
}
this.value = value;
}
public int value() {
return value;
}
}
モデリングについて
冒頭で「本記事ではモデリングにはふれない」と述べましたが、集約の切り方にだけ最小限ふれておきます。(ここ以降の実装は、この集約分割を前提に進みます)
今回は domain/ を category / quiz / challenge の3つの集約に分けました。集約を分ける基準はプロジェクトやドメインによって変わりますが、ここでは次の2点を重視しています。
- 集約をできるだけ小さく保つ(変更影響・ロック競合・理解コストを抑える)
- ビジネスルールが集約の中に閉じるようにする(Application層にできるだけロジックが漏れないようにする)
この2つはトレードオフになります。集約を小さくしすぎると結果としてApplication層に手続き的なロジックが増えがちです。逆に集約を大きくしすぎると、変更の影響範囲が広がり、並行更新の競合も起きやすくなります。どこまでを「同時に一貫して守りたい境界」とみなすかを意識してバランスを取るのがポイントです。
集約ルート
集約ルートは、集約の「入口」となるエンティティです。外部から集約内の要素を参照・更新するときは、必ず集約ルート経由で行います。
今回のモデリングでは Quiz / Category / Challenge が集約ルートです。
Choice は Quiz 集約の内部要素なので、外部から Choice を直接扱うのではなく、Quiz のメソッドを通じて参照・変更します。同様に Answer も Challenge 集約の内部要素として、Challenge を通じて生成・追加します。
実装
ここからは実際のコードを見ていきます。
POST /api/challenges(チャレンジ開始)と POST /api/challenges/{challengeId}/answers(回答送信)の2つのAPIに関するコードを見ていきます。
データの持ち方の違い
3層アーキテクチャ
entityクラスはデータとゲッター/セッターのみを持ち、ロジックを持ちません。DBのテーブルと一対一に対応します(簡単化のため、リレーションに関するアノテーションは省略しています)。
Service層ではentityを直接操作し、ビジネスロジックを実装します。
entity/ChallengeEntity.java
@Entity
@Table(name = "challenge")
public class ChallengeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "category_id")
private Long categoryId;
@Column(name = "started_at")
private LocalDateTime startedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
// ゲッター・セッターのみ、ビジネスロジックは持たない
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public LocalDateTime getStartedAt() { return startedAt; }
public void setStartedAt(LocalDateTime startedAt) { this.startedAt = startedAt; }
public LocalDateTime getCompletedAt() { return completedAt; }
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
}
DDDアーキテクチャ
ドメイン層のエンティティはデータとビジネスロジックを持ちます。
domain/challenge/Challenge.java
public class Challenge {
private final ChallengeId id;
private final CategoryId categoryId;
private final LocalDateTime startedAt;
private LocalDateTime completedAt;
private final List<Answer> answers;
public Challenge(ChallengeId id, CategoryId categoryId, LocalDateTime startedAt,
LocalDateTime completedAt, List<Answer> answers) {
this.id = id;
this.categoryId = categoryId;
this.startedAt = startedAt;
this.completedAt = completedAt;
this.answers = new ArrayList<>(answers);
}
// ファクトリメソッド:新規チャレンジ開始
public static Challenge start(CategoryId categoryId) {
return new Challenge(null, categoryId, LocalDateTime.now(), null, new ArrayList<>());
}
// 集約ルートとしてAnswerの生成も引き受ける
public Answer submitAnswer(QuizId quizId, ChoiceId choiceId, boolean isCorrect, int totalQuizCount) {
if (isCompleted()) {
throw new IllegalStateException("チャレンジは既に完了しています");
}
// Answerのファクトリメソッドを呼び出して生成
Answer answer = Answer.create(quizId, choiceId, this.id, isCorrect);
this.answers.add(answer);
if (this.answers.size() >= totalQuizCount) {
this.completedAt = LocalDateTime.now();
}
return answer;
}
public boolean isCompleted() {
return completedAt != null;
}
// ゲッター(セッターは持たない - 不変性を保つ)
public ChallengeId getId() { return id; }
public CategoryId getCategoryId() { return categoryId; }
public LocalDateTime getStartedAt() { return startedAt; }
public LocalDateTime getCompletedAt() { return completedAt; }
public List<Answer> getAnswers() { return Collections.unmodifiableList(answers); }
}
処理の流れの違い
3層アーキテクチャ
service/ChallengeService.java
@Service
public class ChallengeService {
private final ChallengeRepository challengeRepository;
private final QuizRepository quizRepository;
private final ChoiceRepository choiceRepository;
private final AnswerRepository answerRepository;
// コンストラクタ省略
@Transactional
public ChallengeResponse startChallenge(ChallengeRequest request) {
ChallengeEntity challenge = new ChallengeEntity();
challenge.setCategoryId(request.getCategoryId());
// ビジネスロジック:開始日時を設定
challenge.setStartedAt(LocalDateTime.now());
challengeRepository.save(challenge);
return new ChallengeResponse(challenge.getId(),
challenge.getCategoryId(),
challenge.getStartedAt(),
challenge.getCompletedAt());
}
@Transactional
public AnswerResponse submitAnswer(Long challengeId, AnswerRequest request) {
ChallengeEntity challenge = challengeRepository.findById(challengeId)
.orElseThrow(() -> new RuntimeException("チャレンジが見つかりません"));
// ビジネスロジック:チャレンジが完了済みかチェック
if (challenge.getCompletedAt() != null) {
throw new RuntimeException("チャレンジは既に完了しています");
}
ChoiceEntity choice = choiceRepository.findById(request.getChoiceId())
.orElseThrow(() -> new RuntimeException("選択肢が見つかりません"));
// 回答を保存
AnswerEntity answer = new AnswerEntity();
answer.setChallengeId(challengeId);
answer.setQuizId(request.getQuizId());
answer.setChoiceId(request.getChoiceId());
answer.setCorrect(choice.isCorrect());
answer.setAnsweredAt(LocalDateTime.now());
answerRepository.save(answer);
// ビジネスロジック:全問回答済みならチャレンジを完了
List<QuizEntity> quizzes = quizRepository.findByCategoryId(challenge.getCategoryId());
List<AnswerEntity> answers = answerRepository.findByChallengeId(challengeId);
if (answers.size() >= quizzes.size()) {
// ビジネスロジック:チャレンジ完了日時を設定
challenge.setCompletedAt(LocalDateTime.now());
challengeRepository.save(challenge);
}
return new AnswerResponse(answer.getId(), choice.isCorrect());
}
}
コメントで// ビジネスロジックと書かれた部分がビジネスロジックに相当します。
Service層には、Challenge や Quiz などのビジネスロジックが散在しています。
DDDアーキテクチャ
ビジネスロジックはドメインエンティティ(Challenge)に委譲され、Application層はユースケースの組み立てに専念します。
application/ChallengeService.java
@Service
public class ChallengeService {
private final ChallengeRepository challengeRepository;
private final QuizRepository quizRepository;
// コンストラクタ省略
@Transactional
public ChallengeResponse startChallenge(ChallengeRequest request) {
// 「チャレンジ開始日時を設定する」というビジネスロジックをApplication層が把握していない
Challenge challenge = Challenge.start(new CategoryId(request.getCategoryId()));
challengeRepository.save(challenge);
return new ChallengeResponse(challenge.getId().getValue(),
challenge.getCategoryId().getValue(),
challenge.getStartedAt(),
challenge.getCompletedAt());
}
@Transactional
public AnswerResponse submitAnswer(ChallengeId challengeId, AnswerRequest request) {
Challenge challenge = challengeRepository.findById(challengeId)
.orElseThrow(() -> new RuntimeException("チャレンジが見つかりません"));
Quiz quiz = quizRepository.findById(new QuizId(request.getQuizId()))
.orElseThrow(() -> new RuntimeException("クイズが見つかりません"));
Choice choice = quiz.findChoiceById(new ChoiceId(request.getChoiceId()));
// Application層にビジネスロジックが漏れている
int totalQuizCount = quizRepository.countByCategoryId(challenge.getCategoryId());
Answer answer = challenge.submitAnswer(
quiz.getId(),
choice.getId(),
choice.isCorrect(),
totalQuizCount);
challengeRepository.save(challenge);
return new AnswerResponse(answer.getId().getValue(), answer.isCorrect());
}
}
コメントでも書いた通り、「チャレンジ開始日時を設定する」というビジネスロジックはエンティティに閉じ込められており、Application層はその詳細を知る必要がありません。
データアクセスの違い
3層アーキテクチャ
RepositoryはSpring Data JPAのインターフェースを拡張しています。
repository/ChallengeRepository.java
public interface ChallengeRepository extends JpaRepository<ChallengeEntity, Long> {
List<ChallengeEntity> findByCategoryId(Long categoryId);
}
DDDアーキテクチャ
ドメイン層でリポジトリはインターフェースとして定義され、infrastructure層でその実装が提供されます。
domain/challenge/ChallengeRepository.java
// ドメイン層で定義されるインターフェース
public interface ChallengeRepository {
Optional<Challenge> findById(ChallengeId id);
Challenge save(Challenge challenge);
List<Challenge> findByCategoryId(CategoryId categoryId);
}
Challenge は Answer を内包しているため、ChallengeRepositoryImpl では ChallengeEntity と AnswerEntity の両方を操作する必要があります。
infrastructure/repository/ChallengeRepositoryImpl.java
@Repository
public class ChallengeRepositoryImpl implements ChallengeRepository {
private final ChallengeJpaRepository jpaRepository;
private final AnswerJpaRepository answerJpaRepository;
// コンストラクタ省略
@Override
public Optional<Challenge> findById(ChallengeId id) {
return jpaRepository.findById(id.getValue()).map(this::toDomain);
}
@Override
public Challenge save(Challenge challenge) {
// ドメインモデル → Entity への変換
ChallengeEntity entity = toEntity(challenge);
ChallengeEntity saved = jpaRepository.save(entity);
// Answersも保存
for (Answer answer : challenge.getAnswers()) {
AnswerEntity answerEntity = toAnswerEntity(answer);
answerJpaRepository.save(answerEntity);
}
return toDomain(saved);
}
@Override
public List<Challenge> findByCategoryId(CategoryId categoryId) {
List<ChallengeEntity> entities = jpaRepository.findByCategoryId(categoryId.getValue());
List<Challenge> challenges = new ArrayList<>();
for (ChallengeEntity entity : entities) {
challenges.add(toDomain(entity));
}
return challenges;
}
// Entity → ドメインモデル への変換
private Challenge toDomain(ChallengeEntity entity) {
// 実装の詳細は省略
return challenge;
}
// ドメインモデル → Entity への変換
private ChallengeEntity toEntity(Challenge challenge) {
// 実装の詳細は省略
return entity;
}
private AnswerEntity toAnswerEntity(Answer answer) {
// 実装の詳細は省略
return answerEntity;
}
private Answer toAnswerDomain(AnswerEntity entity) {
// 実装の詳細は省略
return answer;
}
}
まとめ
DDDの実装方法を学び始め、依存関係の逆転や値オブジェクト、振る舞いを持つエンティティなどの概念を理解した段階で、DDDを「完全に理解した」と思っていました。
しかし、実際に手を動かしてみると、どの単位で集約を分けるべきか、Domain層とApplication層のどちらにロジックを置くべきか、といった設計上の判断に迷うことが多々ありました。
最初からDDDの設計原則を完璧に守るのは非常に難しいですが、値オブジェクトや依存関係の逆転といったDDDの手法は、それだけでもコードの品質向上に役立ちます。
段階的にDDDの考え方を取り入れながら、「ちょっとできる」状態を目指して学習していきたいと思います。