Next.js 15とDDDで作る堅牢なシステム設計【第2部: Domain層編】
はじめに
[第1部: 設計編]では、DDDの基礎とレイヤードアーキテクチャの全体像を解説しました。
第2部では、DDDの心臓部であるDomain層の実装に焦点を当てます。エンティティ、値オブジェクト、リポジトリパターンを実際のコードで解説し、ビジネスロジックをどのように設計・実装するかを学びます。
この記事で学べること
- エンティティの設計思想と実装
- 値オブジェクトの使い方
- リポジトリパターンの理解
- 不変性とファクトリメソッド
- ドメインロジックのテスト手法
前提知識
- [第1部: 設計編]を読んでいること
- TypeScriptの基本構文を理解していること
- クラスとインターフェースの概念を知っていること
Domain層の設計思想
Domain層は、アプリケーションの心臓部です。ここには純粋なビジネスロジックのみを配置し、フレームワークやライブラリに一切依存しません。
Domain層の原則
1. 純粋なTypeScriptで実装
// ✅ 良い例: 純粋なTypeScript
class Quiz {
private props: QuizProps;
publish(): Quiz {
// ビジネスロジックのみ
}
}
// ❌ 悪い例: フレームワークに依存
import { useState } from 'react'; // React依存
import { doc } from 'firebase/firestore'; // Firebase依存
class Quiz {
// これはDomain層ではない
}
2. 技術的詳細から独立
// Domain層はFirebaseを知らない
class Quiz {
// データベースアクセスなし
// API呼び出しなし
// ファイルI/Oなし
}
3. ビジネス用語で表現
// ✅ ビジネス用語
class Quiz {
publish() { } // 公開する
unpublish() { } // 非公開にする
changeTitle() { } // タイトルを変更する
}
// ❌ 技術用語
class Quiz {
setFlag() { } // 何のフラグ?
updateData() { } // 何のデータ?
}
エンティティとは
エンティティは、一意の識別子を持ち、ライフサイクルを通じて同一性を保つオブジェクトです。
エンティティの特徴
-
一意の識別子(ID)を持つ
- 同じ属性でもIDが異なれば別物
-
ライフサイクルがある
- 作成 → 更新 → 削除
-
可変である
- 属性は変更可能(ただし新インスタンスを返す)
-
等価性はIDで判定
- 属性が変わってもIDが同じなら同一
エンティティの例
const quiz1 = Quiz.create({ title: 'クイズA', ... });
const quiz2 = quiz1.changeTitle('クイズB');
console.log(quiz1.id === quiz2.id); // true(同一のクイズ)
console.log(quiz1.title); // 'クイズA'
console.log(quiz2.title); // 'クイズB'
クイズエンティティの実装
それでは、実際にクイズエンティティを実装していきます。
基本構造
// domain/quiz/entities/quiz.ts
import { Question } from './question';
export type QuizId = string;
export type CategoryId = string;
export interface QuizProps {
id: QuizId;
title: string;
categoryId: CategoryId;
questions: Question[];
difficulty: 'easy' | 'medium' | 'hard';
isPublished: boolean;
createdAt: Date;
updatedAt: Date;
}
export class Quiz {
// privateコンストラクタ: 外部から直接newできない
private constructor(private props: QuizProps) {
this.validate();
}
// ... メソッドは後述
}
なぜprivateコンストラクタ?
// ❌ publicコンストラクタの問題
const quiz = new Quiz({
id: '123',
title: 'a', // 短すぎるタイトル
// ... 不正なデータでも作成できてしまう
});
// ✅ privateコンストラクタ + ファクトリメソッド
const quiz = Quiz.create({
title: 'a', // エラー!ファクトリメソッド内で検証される
});
生成ロジックを制御し、不正な状態のオブジェクト生成を防ぎます。
ファクトリメソッド
新規作成用ファクトリ
/**
* ファクトリメソッド: 新規作成
* ビジネスルールに従った生成を保証
*/
static create(params: {
title: string;
categoryId: CategoryId;
questions: Question[];
difficulty: QuizProps['difficulty'];
}): Quiz {
return new Quiz({
id: crypto.randomUUID(), // IDを自動生成
title: params.title,
categoryId: params.categoryId,
questions: params.questions,
difficulty: params.difficulty,
isPublished: false, // 新規作成時は必ず非公開
createdAt: new Date(),
updatedAt: new Date(),
});
}
ポイント:
- IDは自動生成
-
isPublishedは必ずfalse(ビジネスルール) - タイムスタンプを自動設定
復元用ファクトリ
/**
* ファクトリメソッド: DB復元時
* 既存データをドメインオブジェクトに変換
*/
static reconstruct(props: QuizProps): Quiz {
return new Quiz(props);
}
使い分け:
-
create: ユーザーが新しくクイズを作る時 -
reconstruct: データベースから取得したデータを復元する時
バリデーション
不変条件の保護
/**
* 不変条件(invariants)の保護
* エンティティが常に妥当な状態であることを保証
*/
private validate(): void {
// タイトルの長さチェック
if (this.props.title.trim().length < 3) {
throw new Error('クイズタイトルは3文字以上である必要があります');
}
if (this.props.title.length > 100) {
throw new Error('クイズタイトルは100文字以内である必要があります');
}
// 質問数のチェック
if (this.props.questions.length < 1) {
throw new Error('クイズには最低1つの質問が必要です');
}
if (this.props.questions.length > 50) {
throw new Error('クイズの質問数は50問以下である必要があります');
}
}
不変条件とは:
- エンティティが常に満たすべき条件
- コンストラクタで必ずチェック
- 違反したらエラーを投げる
Getters
// Getters: 外部からプロパティを安全に取得
get id(): QuizId {
return this.props.id;
}
get title(): string {
return this.props.title;
}
get categoryId(): CategoryId {
return this.props.categoryId;
}
get questions(): Question[] {
return [...this.props.questions]; // 防御的コピー
}
get difficulty(): QuizProps['difficulty'] {
return this.props.difficulty;
}
get isPublished(): boolean {
return this.props.isPublished;
}
get createdAt(): Date {
return new Date(this.props.createdAt); // 防御的コピー
}
get updatedAt(): Date {
return new Date(this.props.updatedAt);
}
なぜ防御的コピー?
// ❌ 防御的コピーなし
get questions(): Question[] {
return this.props.questions; // 参照を返す
}
const quiz = Quiz.create(...);
const questions = quiz.questions;
questions.push(newQuestion); // 外部から内部状態を変更できてしまう!
// ✅ 防御的コピー
get questions(): Question[] {
return [...this.props.questions]; // コピーを返す
}
const questions = quiz.questions;
questions.push(newQuestion); // コピーなので内部状態は変わらない
ビジネスロジックの実装
タイトル変更
/**
* ビジネスロジック: タイトル変更
* 不変性を保つため、新しいインスタンスを返す
*/
changeTitle(newTitle: string): Quiz {
return new Quiz({
...this.props,
title: newTitle,
updatedAt: new Date(),
});
}
不変性のメリット:
- 予期しない変更を防ぐ
- 変更履歴の追跡が容易
- 並行処理でも安全
質問の操作
/**
* ビジネスロジック: 質問の追加
*/
addQuestion(question: Question): Quiz {
return new Quiz({
...this.props,
questions: [...this.props.questions, question],
updatedAt: new Date(),
});
}
/**
* ビジネスロジック: 質問の更新
*/
updateQuestion(index: number, question: Question): Quiz {
if (index < 0 || index >= this.props.questions.length) {
throw new Error('無効な質問インデックスです');
}
const newQuestions = [...this.props.questions];
newQuestions[index] = question;
return new Quiz({
...this.props,
questions: newQuestions,
updatedAt: new Date(),
});
}
/**
* ビジネスロジック: 質問の削除
*/
removeQuestion(index: number): Quiz {
if (index < 0 || index >= this.props.questions.length) {
throw new Error('無効な質問インデックスです');
}
if (this.props.questions.length <= 1) {
throw new Error('クイズには最低1つの質問が必要です');
}
const newQuestions = this.props.questions.filter((_, i) => i !== index);
return new Quiz({
...this.props,
questions: newQuestions,
updatedAt: new Date(),
});
}
公開処理(重要なビジネスロジック)
/**
* ビジネスロジック: 公開処理
* 公開前の厳格な検証を含む
*/
publish(): Quiz {
// 既に公開済みチェック
if (this.props.isPublished) {
throw new Error('既に公開されているクイズです');
}
// 公開に必要な条件をチェック
if (this.props.questions.length < 3) {
throw new Error('公開するには最低3つの質問が必要です');
}
// すべての質問が完全であることを確認
const allQuestionsValid = this.props.questions.every(q =>
q.text.trim().length > 0 &&
q.choices.length >= 2 &&
q.correctAnswerIndex >= 0 &&
q.correctAnswerIndex < q.choices.length
);
if (!allQuestionsValid) {
throw new Error('すべての質問が正しく設定されている必要があります');
}
return new Quiz({
...this.props,
isPublished: true,
updatedAt: new Date(),
});
}
/**
* ビジネスロジック: 公開取り消し
*/
unpublish(): Quiz {
if (!this.props.isPublished) {
throw new Error('公開されていないクイズです');
}
return new Quiz({
...this.props,
isPublished: false,
updatedAt: new Date(),
});
}
ビジネスルールの集約:
- 「公開には3問以上必要」というルールはここだけ
- 変更時はこのメソッドを修正すれば良い
- 他の場所に同じロジックが散在しない
その他のビジネスロジック
/**
* カテゴリ変更
*/
changeCategory(newCategoryId: CategoryId): Quiz {
return new Quiz({
...this.props,
categoryId: newCategoryId,
updatedAt: new Date(),
});
}
/**
* 難易度変更
*/
changeDifficulty(newDifficulty: QuizProps['difficulty']): Quiz {
return new Quiz({
...this.props,
difficulty: newDifficulty,
updatedAt: new Date(),
});
}
永続化用変換
/**
* プレーンオブジェクトへの変換
* Infrastructure層での永続化時に使用
*/
toObject(): QuizProps {
return {
id: this.props.id,
title: this.props.title,
categoryId: this.props.categoryId,
questions: this.props.questions.map(q => q.toObject()),
difficulty: this.props.difficulty,
isPublished: this.props.isPublished,
createdAt: this.props.createdAt,
updatedAt: this.props.updatedAt,
};
}
値オブジェクトの実装
値オブジェクトは、等価性が値で判定され、完全に不変なオブジェクトです。
値オブジェクトの特徴
- 識別子を持たない
- 等価性は値で判定
- 完全に不変(immutable)
- 自己検証する
質問値オブジェクト
// domain/quiz/entities/question.ts
export interface QuestionProps {
text: string;
choices: string[];
correctAnswerIndex: number;
explanation?: string;
}
export class Question {
private constructor(private props: QuestionProps) {
this.validate();
}
static create(params: QuestionProps): Question {
return new Question(params);
}
static reconstruct(props: QuestionProps): Question {
return new Question(props);
}
/**
* ビジネスルールの検証
*/
private validate(): void {
// 質問文の検証
if (this.props.text.trim().length < 5) {
throw new Error('質問文は5文字以上である必要があります');
}
if (this.props.text.length > 500) {
throw new Error('質問文は500文字以内である必要があります');
}
// 選択肢の検証
if (this.props.choices.length < 2) {
throw new Error('選択肢は最低2つ必要です');
}
if (this.props.choices.length > 6) {
throw new Error('選択肢は6つ以下である必要があります');
}
// すべての選択肢が空でないことを確認
const hasEmptyChoice = this.props.choices.some(
choice => choice.trim().length === 0
);
if (hasEmptyChoice) {
throw new Error('すべての選択肢に内容が必要です');
}
// 正解インデックスの検証
if (
this.props.correctAnswerIndex < 0 ||
this.props.correctAnswerIndex >= this.props.choices.length
) {
throw new Error('正解インデックスが無効です');
}
// 解説の検証
if (this.props.explanation && this.props.explanation.length > 1000) {
throw new Error('解説は1000文字以内である必要があります');
}
}
// Getters
get text(): string {
return this.props.text;
}
get choices(): string[] {
return [...this.props.choices]; // 防御的コピー
}
get correctAnswerIndex(): number {
return this.props.correctAnswerIndex;
}
get explanation(): string | undefined {
return this.props.explanation;
}
/**
* 値オブジェクトの等価性比較
* 識別子ではなく、すべての属性で比較
*/
equals(other: Question): boolean {
if (this.props.text !== other.props.text) return false;
if (this.props.correctAnswerIndex !== other.props.correctAnswerIndex) return false;
if (this.props.explanation !== other.props.explanation) return false;
if (this.props.choices.length !== other.props.choices.length) return false;
return this.props.choices.every(
(choice, index) => choice === other.props.choices[index]
);
}
/**
* 新しい質問オブジェクトを返す(不変性を保つ)
*/
changeText(newText: string): Question {
return new Question({
...this.props,
text: newText,
});
}
changeChoices(newChoices: string[]): Question {
return new Question({
...this.props,
choices: newChoices,
});
}
changeCorrectAnswer(newIndex: number): Question {
return new Question({
...this.props,
correctAnswerIndex: newIndex,
});
}
changeExplanation(newExplanation?: string): Question {
return new Question({
...this.props,
explanation: newExplanation,
});
}
toObject(): QuestionProps {
return {
text: this.props.text,
choices: [...this.props.choices],
correctAnswerIndex: this.props.correctAnswerIndex,
explanation: this.props.explanation,
};
}
}
エンティティ vs 値オブジェクト
| 特徴 | エンティティ | 値オブジェクト |
|---|---|---|
| 識別子 | あり(ID) | なし |
| 等価性 | IDで判定 | すべての属性で判定 |
| 可変性 | 属性は変更可能 | 完全に不変 |
| ライフサイクル | あり | なし |
| 例 | Quiz, User, Order | Question, Money, Address, Email |
カテゴリエンティティ
カテゴリも同様に実装します。
// domain/category/entities/category.ts
export type CategoryId = string;
export interface CategoryProps {
id: CategoryId;
name: string;
description?: string;
color: string;
order: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class Category {
private constructor(private props: CategoryProps) {
this.validate();
}
static create(params: {
name: string;
description?: string;
color: string;
order: number;
}): Category {
return new Category({
id: crypto.randomUUID(),
name: params.name,
description: params.description,
color: params.color,
order: params.order,
isActive: true, // 新規作成時は必ずアクティブ
createdAt: new Date(),
updatedAt: new Date(),
});
}
static reconstruct(props: CategoryProps): Category {
return new Category(props);
}
private validate(): void {
if (this.props.name.trim().length < 2) {
throw new Error('カテゴリ名は2文字以上である必要があります');
}
if (this.props.name.length > 50) {
throw new Error('カテゴリ名は50文字以内である必要があります');
}
if (this.props.description && this.props.description.length > 200) {
throw new Error('説明は200文字以内である必要があります');
}
if (this.props.order < 0) {
throw new Error('表示順序は0以上である必要があります');
}
}
// Getters
get id(): CategoryId { return this.props.id; }
get name(): string { return this.props.name; }
get description(): string | undefined { return this.props.description; }
get color(): string { return this.props.color; }
get order(): number { return this.props.order; }
get isActive(): boolean { return this.props.isActive; }
get createdAt(): Date { return new Date(this.props.createdAt); }
get updatedAt(): Date { return new Date(this.props.updatedAt); }
// ビジネスロジック
changeName(newName: string): Category {
return new Category({
...this.props,
name: newName,
updatedAt: new Date(),
});
}
activate(): Category {
if (this.props.isActive) {
throw new Error('既にアクティブなカテゴリです');
}
return new Category({
...this.props,
isActive: true,
updatedAt: new Date(),
});
}
deactivate(): Category {
if (!this.props.isActive) {
throw new Error('既に非アクティブなカテゴリです');
}
return new Category({
...this.props,
isActive: false,
updatedAt: new Date(),
});
}
toObject(): CategoryProps {
return { ...this.props };
}
}
リポジトリパターン
リポジトリは、永続化の詳細をドメイン層から隠蔽するためのパターンです。
リポジトリインターフェース
Domain層ではインターフェースのみを定義します。
// domain/quiz/repositories/quiz-repository.ts
import { Quiz, QuizId, CategoryId } from '../entities/quiz';
/**
* クイズリポジトリインターフェース
* 永続化の詳細をドメイン層から隠蔽
*/
export interface QuizRepository {
// 保存(新規作成または更新)
save(quiz: Quiz): Promise<void>;
// 取得
findById(id: QuizId): Promise<Quiz | null>;
findAll(): Promise<Quiz[]>;
findByCategoryId(categoryId: CategoryId): Promise<Quiz[]>;
findPublished(): Promise<Quiz[]>;
// 削除
delete(id: QuizId): Promise<void>;
// 条件検索
findByCondition(condition: {
categoryId?: CategoryId;
isPublished?: boolean;
difficulty?: 'easy' | 'medium' | 'hard';
}): Promise<Quiz[]>;
}
// domain/category/repositories/category-repository.ts
import { Category, CategoryId } from '../entities/category';
export interface CategoryRepository {
save(category: Category): Promise<void>;
findById(id: CategoryId): Promise<Category | null>;
findAll(): Promise<Category[]>;
findActive(): Promise<Category[]>;
delete(id: CategoryId): Promise<void>;
}
なぜインターフェースで分離するのか?
1. 技術的詳細の隠蔽
// Domain層はFirebaseを知らない
class Quiz {
publish() {
// データベースの種類は関係ない
}
}
2. テスタビリティの向上
// テスト用のモックリポジトリ
class MockQuizRepository implements QuizRepository {
private quizzes = new Map<QuizId, Quiz>();
async save(quiz: Quiz): Promise<void> {
this.quizzes.set(quiz.id, quiz);
}
async findById(id: QuizId): Promise<Quiz | null> {
return this.quizzes.get(id) || null;
}
}
3. 技術スタックの変更容易性
// Firebase実装
class FirebaseQuizRepository implements QuizRepository { }
// PostgreSQL実装
class PostgresQuizRepository implements QuizRepository { }
// 実装を切り替えるだけでOK
ドメインロジックのテスト
Domain層は外部依存がないため、テストが最も容易です。
テスト環境のセットアップ
npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});
クイズエンティティのテスト
// domain/quiz/entities/quiz.test.ts
import { describe, test, expect } from 'vitest';
import { Quiz } from './quiz';
import { Question } from './question';
describe('Quiz Entity', () => {
// ヘルパー関数
const createValidQuestion = () => Question.create({
text: 'テスト質問文',
choices: ['選択肢1', '選択肢2'],
correctAnswerIndex: 0,
});
describe('create', () => {
test('正常にクイズを作成できる', () => {
const quiz = Quiz.create({
title: 'テストクイズ',
categoryId: 'cat-1',
questions: [createValidQuestion()],
difficulty: 'easy',
});
expect(q1.equals(q2)).toBe(false);
});
});
describe('immutability', () => {
test('changeTextは新しいインスタンスを返す', () => {
const original = Question.create({
text: '元の質問',
choices: ['A', 'B'],
correctAnswerIndex: 0,
});
const changed = original.changeText('新しい質問');
expect(changed.text).toBe('新しい質問');
expect(original.text).toBe('元の質問'); // 元は変わらない
});
});
});
テストのベストプラクティス
1. Given-When-Then パターン
test('公開処理のテスト', () => {
// Given: 前提条件
const quiz = Quiz.create({
title: 'テストクイズ',
categoryId: 'cat-1',
questions: [
createValidQuestion(),
createValidQuestion(),
createValidQuestion(),
],
difficulty: 'easy',
});
// When: 実行
const published = quiz.publish();
// Then: 検証
expect(published.isPublished).toBe(true);
});
2. テストケースの命名
// ✅ 良い命名
test('3問未満では公開できない', () => { });
test('タイトルが短すぎるとエラー', () => { });
// ❌ 悪い命名
test('test1', () => { });
test('エラーチェック', () => { });
3. 境界値テスト
describe('タイトル長のバリデーション', () => {
test('2文字はエラー', () => {
expect(() => Quiz.create({ title: 'ab', ... })).toThrow();
});
test('3文字はOK', () => {
expect(() => Quiz.create({ title: 'abc', ... })).not.toThrow();
});
test('100文字はOK', () => {
expect(() => Quiz.create({ title: 'a'.repeat(100), ... })).not.toThrow();
});
test('101文字はエラー', () => {
expect(() => Quiz.create({ title: 'a'.repeat(101), ... })).toThrow();
});
});
実践的なパターン
1. ドメインサービス
複数のエンティティにまたがるロジックは、ドメインサービスに配置します。
// domain/quiz/services/quiz-validator.ts
import { Quiz } from '../entities/quiz';
import { Category } from '../../category/entities/category';
/**
* ドメインサービス
* 複数のエンティティにまたがるビジネスロジック
*/
export class QuizValidator {
/**
* クイズが指定されたカテゴリで公開可能かチェック
*/
static canPublishInCategory(quiz: Quiz, category: Category): boolean {
// カテゴリがアクティブでなければ公開できない
if (!category.isActive) {
return false;
}
// クイズの基本的な公開条件もチェック
if (quiz.questions.length < 3) {
return false;
}
return true;
}
/**
* クイズの重複チェック
*/
static isDuplicate(quiz: Quiz, existingQuizzes: Quiz[]): boolean {
return existingQuizzes.some(existing =>
existing.title === quiz.title &&
existing.categoryId === quiz.categoryId
);
}
}
使用例:
const quiz = Quiz.create({ ... });
const category = await categoryRepository.findById(quiz.categoryId);
if (!QuizValidator.canPublishInCategory(quiz, category)) {
throw new Error('このカテゴリでは公開できません');
}
2. ドメインイベント(オプション)
重要なビジネスイベントを表現します。
// domain/quiz/events/quiz-events.ts
export interface DomainEvent {
occurredAt: Date;
}
export class QuizPublished implements DomainEvent {
readonly occurredAt: Date;
constructor(
public readonly quizId: string,
public readonly title: string
) {
this.occurredAt = new Date();
}
}
export class QuizUnpublished implements DomainEvent {
readonly occurredAt: Date;
constructor(public readonly quizId: string) {
this.occurredAt = new Date();
}
}
エンティティでの使用:
class Quiz {
private events: DomainEvent[] = [];
publish(): Quiz {
// 検証...
const published = new Quiz({
...this.props,
isPublished: true,
});
// イベントを記録
published.addEvent(new QuizPublished(this.id, this.title));
return published;
}
private addEvent(event: DomainEvent): void {
this.events.push(event);
}
getEvents(): DomainEvent[] {
return [...this.events];
}
clearEvents(): void {
this.events = [];
}
}
3. 仕様パターン(Specification Pattern)
複雑な条件判定を再利用可能にします。
// domain/quiz/specifications/quiz-specification.ts
import { Quiz } from '../entities/quiz';
export interface Specification<T> {
isSatisfiedBy(candidate: T): boolean;
}
export class PublishableQuizSpecification implements Specification<Quiz> {
isSatisfiedBy(quiz: Quiz): boolean {
return (
quiz.questions.length >= 3 &&
quiz.questions.every(q =>
q.text.trim().length > 0 &&
q.choices.length >= 2
)
);
}
}
export class DraftQuizSpecification implements Specification<Quiz> {
isSatisfiedBy(quiz: Quiz): boolean {
return !quiz.isPublished;
}
}
// 仕様の組み合わせ
export class AndSpecification<T> implements Specification<T> {
constructor(
private spec1: Specification<T>,
private spec2: Specification<T>
) {}
isSatisfiedBy(candidate: T): boolean {
return this.spec1.isSatisfiedBy(candidate) &&
this.spec2.isSatisfiedBy(candidate);
}
}
使用例:
const publishableSpec = new PublishableQuizSpecification();
const draftSpec = new DraftQuizSpecification();
const canBePublished = new AndSpecification(publishableSpec, draftSpec);
if (canBePublished.isSatisfiedBy(quiz)) {
const published = quiz.publish();
}
よくある質問
Q1: エンティティと値オブジェクトの判断基準は?
判断のポイント:
-
識別子が必要か?
- 必要 → エンティティ
- 不要 → 値オブジェクト
-
ライフサイクルがあるか?
- ある(作成→更新→削除) → エンティティ
- ない → 値オブジェクト
-
交換可能か?
- 不可(同じIDなら同一) → エンティティ
- 可能(値が同じなら同一) → 値オブジェクト
例:
// エンティティ: クイズは識別子で管理
const quiz1 = Quiz.create({ title: 'テスト' });
const quiz2 = Quiz.create({ title: 'テスト' });
// quiz1とquiz2は別物(IDが異なる)
// 値オブジェクト: 質問は値で判定
const q1 = Question.create({ text: 'テスト', choices: ['A', 'B'], ... });
const q2 = Question.create({ text: 'テスト', choices: ['A', 'B'], ... });
// q1とq2は同じ(値が同じ)
Q2: すべてのメソッドで新しいインスタンスを返す必要がある?
はい、不変性を保つために必要です。
// ✅ 正しい
changeTitle(newTitle: string): Quiz {
return new Quiz({ ...this.props, title: newTitle });
}
// ❌ 間違い
changeTitle(newTitle: string): void {
this.props.title = newTitle; // ミュータブル
}
メリット:
- 予期しない変更を防ぐ
- タイムトラベルデバッグが可能
- 並行処理でも安全
Q3: バリデーションはどこで行うべき?
二重バリデーションを推奨します。
入力
↓
Zodバリデーション(形式チェック)
↓
ドメインエンティティ(ビジネスルールチェック)
役割分担:
- Zodバリデーション: 型、形式、基本的な条件
- ドメインバリデーション: 複雑なビジネスルール
// Zod: 形式チェック
const schema = z.object({
title: z.string().min(1).max(100),
questions: z.array(questionSchema).min(1),
});
// Domain: ビジネスルール
class Quiz {
private validate() {
if (this.props.questions.length > 50) {
throw new Error('質問は50問以下'); // ビジネスルール
}
}
}
Q4: Domain層でDateを使っても良い?
はい、標準ライブラリは使えます。
// ✅ OK: 標準ライブラリ
class Quiz {
get createdAt(): Date {
return new Date(this.props.createdAt);
}
}
// ❌ NG: 外部ライブラリ
import dayjs from 'dayjs'; // Domain層で外部ライブラリは避ける
原則:
- 標準ライブラリ(Date, Math, String等): OK
- 外部ライブラリ: NG
Q5: パフォーマンスは大丈夫?
ほとんどの場合、問題ありません。
// 不変操作のベンチマーク(参考値)
const iterations = 100000;
// ミュータブル: ~2ms
// イミュータブル: ~15ms
// 差: 13ms(10万回の操作で)
考察:
- 10万回の操作で13ms程度の差
- データベースアクセス(数十〜数百ms)の方が遥かにボトルネック
- 実用上は無視できるレベル
まとめ
第2部では、Domain層の実装について詳しく解説しました。
重要なポイント
1. エンティティの設計
- privateコンストラクタ + ファクトリメソッド
- 不変性の徹底(新インスタンスを返す)
- 自己検証(不変条件の保護)
- ビジネスロジックの集約
2. 値オブジェクト
- 識別子を持たない
- 等価性は値で判定
- 完全に不変
- 自己検証
3. リポジトリパターン
- Domain層はインターフェースのみ
- 技術的詳細の隠蔽
- テスタビリティの向上
4. テスト戦略
- 外部依存なしでテスト可能
- Given-When-Then パターン
- 境界値テスト
- 不変性の検証
Domain層の価値
// ビジネスルールがコードで表現される
const quiz = Quiz.create({ ... });
const published = quiz.publish(); // 「公開する」という意図が明確
// テストが容易
expect(() => quiz.publish()).toThrow('最低3問必要');
// 変更に強い
// ルール変更時は1箇所だけ修正すれば良い
次回予告
**【第3部: Infrastructure & Application層編】**では、以下を解説します:
- Firebaseとの連携実装
- リポジトリの具体的実装
- Server Actionsの実装
- Zodバリデーション
- エラーハンドリング戦略
- DTOパターンの活用
Domain層で作ったビジネスロジックを、実際にNext.js 15とFirebaseで動かしていきます!
参考資料
- Domain-Driven Design by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Vitest Documentation
サンプルコード
本記事で紹介したコードの完全版は、GitHubで公開予定です。
この記事が役に立ったら、いいねやコメントをいただけると嬉しいです!
質問やフィードバックもお待ちしています。
次回【第3部: Infrastructure & Application層編】もお楽しみに!
Discussion