💎

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() { }    // 何のデータ?
}

エンティティとは

エンティティは、一意の識別子を持ち、ライフサイクルを通じて同一性を保つオブジェクトです。

エンティティの特徴

  1. 一意の識別子(ID)を持つ

    • 同じ属性でもIDが異なれば別物
  2. ライフサイクルがある

    • 作成 → 更新 → 削除
  3. 可変である

    • 属性は変更可能(ただし新インスタンスを返す)
  4. 等価性は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,
  };
}

値オブジェクトの実装

値オブジェクトは、等価性が値で判定され、完全に不変なオブジェクトです。

値オブジェクトの特徴

  1. 識別子を持たない
  2. 等価性は値で判定
  3. 完全に不変(immutable)
  4. 自己検証する

質問値オブジェクト

// 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: エンティティと値オブジェクトの判断基準は?

判断のポイント:

  1. 識別子が必要か?

    • 必要 → エンティティ
    • 不要 → 値オブジェクト
  2. ライフサイクルがあるか?

    • ある(作成→更新→削除) → エンティティ
    • ない → 値オブジェクト
  3. 交換可能か?

    • 不可(同じ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で動かしていきます!


参考資料

サンプルコード

本記事で紹介したコードの完全版は、GitHubで公開予定です。


この記事が役に立ったら、いいねやコメントをいただけると嬉しいです!
質問やフィードバックもお待ちしています。

次回【第3部: Infrastructure & Application層編】もお楽しみに!

Discussion