🐥

Next.js 15とDDDで作る堅牢なシステム設計【第3部: Infrastructure & Application層編】

に公開

はじめに

[第1部: 設計編]ではアーキテクチャ全体を、[第2部: Domain層編]ではビジネスロジックの実装を解説しました。

第3部では、Infrastructure層とApplication層の実装に焦点を当てます。Domain層で作ったビジネスロジックを、実際にFirebaseと連携させ、Next.js 15のServer Actionsで動かす方法を学びます。

この記事で学べること

  • Firebaseとの連携実装
  • リポジトリパターンの具体的実装
  • Server Actionsの設計と実装
  • Zodによるバリデーション戦略
  • エラーハンドリングのベストプラクティス
  • DTOパターンの活用

前提知識

  • [第1部: 設計編]と[第2部: Domain層編]を読んでいること
  • Firebaseの基本的な使い方を知っていること
  • Next.js 13以降のApp Routerを理解していること

Infrastructure層の実装

Infrastructure層は、技術的な詳細を扱う層です。ここでは、Domain層のリポジトリインターフェースを具体的に実装します。

Firebase設定

環境変数の準備

# .env.local

FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

セキュリティのポイント:

  • .env.local.gitignoreに追加
  • サービスアカウントキーは厳重に管理
  • 本番環境では環境変数を使用

Firebase Admin SDKの初期化

// infrastructure/firebase/config.ts

import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

/**
 * Firebase Admin SDK初期化
 * シングルトンパターンで複数回の初期化を防ぐ
 */
if (!getApps().length) {
  initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      // 改行文字を正しく処理
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
    }),
  });
}

export const db = getFirestore();

// コレクション名の一元管理
export const COLLECTIONS = {
  QUIZZES: 'quizzes',
  CATEGORIES: 'categories',
} as const;

設計のポイント:

  • シングルトンパターン: getApps().lengthで初期化済みかチェック
  • 定数管理: コレクション名を一箇所で管理
  • 型安全性: as constで文字列リテラル型に

リポジトリの実装

Domain層で定義したインターフェースを実装します。

クイズリポジトリの実装

// infrastructure/firebase/repositories/firebase-quiz-repository.ts

import { Quiz, QuizId, CategoryId } from '@/domain/quiz/entities/quiz';
import { Question } from '@/domain/quiz/entities/question';
import { QuizRepository } from '@/domain/quiz/repositories/quiz-repository';
import { db, COLLECTIONS } from '../config';

/**
 * Firestoreドキュメント型
 * ドメインモデルとは別に定義することで、永続化形式を柔軟に変更可能
 */
interface QuizDocument {
  id: string;
  title: string;
  categoryId: string;
  questions: {
    text: string;
    choices: string[];
    correctAnswerIndex: number;
    explanation?: string;
  }[];
  difficulty: 'easy' | 'medium' | 'hard';
  isPublished: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export class FirebaseQuizRepository implements QuizRepository {
  private collection = db.collection(COLLECTIONS.QUIZZES);

  /**
   * ドメインモデル → Firestoreドキュメント
   * 永続化に適した形式に変換
   */
  private toDocument(quiz: Quiz): QuizDocument {
    const quizObj = quiz.toObject();
    return {
      id: quizObj.id,
      title: quizObj.title,
      categoryId: quizObj.categoryId,
      questions: quizObj.questions,
      difficulty: quizObj.difficulty,
      isPublished: quizObj.isPublished,
      createdAt: quizObj.createdAt,
      updatedAt: quizObj.updatedAt,
    };
  }

  /**
   * Firestoreドキュメント → ドメインモデル
   * 永続化形式からドメインオブジェクトを復元
   */
  private toDomain(doc: QuizDocument): Quiz {
    return Quiz.reconstruct({
      id: doc.id,
      title: doc.title,
      categoryId: doc.categoryId,
      questions: doc.questions.map(q => Question.reconstruct(q)),
      difficulty: doc.difficulty,
      isPublished: doc.isPublished,
      createdAt: doc.createdAt,
      updatedAt: doc.updatedAt,
    });
  }

  /**
   * クイズを保存(新規作成または更新)
   */
  async save(quiz: Quiz): Promise<void> {
    try {
      const doc = this.toDocument(quiz);
      await this.collection.doc(quiz.id).set(doc);
    } catch (error) {
      console.error('Failed to save quiz:', error);
      throw new Error('クイズの保存に失敗しました');
    }
  }

  /**
   * IDでクイズを取得
   */
  async findById(id: QuizId): Promise<Quiz | null> {
    try {
      const docSnap = await this.collection.doc(id).get();
      
      if (!docSnap.exists) {
        return null;
      }

      const data = docSnap.data() as QuizDocument;
      return this.toDomain(data);
    } catch (error) {
      console.error('Failed to find quiz:', error);
      throw new Error('クイズの取得に失敗しました');
    }
  }

  /**
   * すべてのクイズを取得
   */
  async findAll(): Promise<Quiz[]> {
    try {
      const snapshot = await this.collection
        .orderBy('createdAt', 'desc')
        .get();
      
      return snapshot.docs.map(doc => {
        const data = doc.data() as QuizDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find all quizzes:', error);
      throw new Error('クイズ一覧の取得に失敗しました');
    }
  }

  /**
   * カテゴリIDでクイズを取得
   */
  async findByCategoryId(categoryId: CategoryId): Promise<Quiz[]> {
    try {
      const snapshot = await this.collection
        .where('categoryId', '==', categoryId)
        .orderBy('createdAt', 'desc')
        .get();

      return snapshot.docs.map(doc => {
        const data = doc.data() as QuizDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find quizzes by category:', error);
      throw new Error('カテゴリ別クイズの取得に失敗しました');
    }
  }

  /**
   * 公開済みクイズを取得
   */
  async findPublished(): Promise<Quiz[]> {
    try {
      const snapshot = await this.collection
        .where('isPublished', '==', true)
        .orderBy('createdAt', 'desc')
        .get();

      return snapshot.docs.map(doc => {
        const data = doc.data() as QuizDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find published quizzes:', error);
      throw new Error('公開済みクイズの取得に失敗しました');
    }
  }

  /**
   * クイズを削除
   */
  async delete(id: QuizId): Promise<void> {
    try {
      await this.collection.doc(id).delete();
    } catch (error) {
      console.error('Failed to delete quiz:', error);
      throw new Error('クイズの削除に失敗しました');
    }
  }

  /**
   * 条件検索
   */
  async findByCondition(condition: {
    categoryId?: CategoryId;
    isPublished?: boolean;
    difficulty?: 'easy' | 'medium' | 'hard';
  }): Promise<Quiz[]> {
    try {
      let query = this.collection.orderBy('createdAt', 'desc');

      if (condition.categoryId) {
        query = query.where('categoryId', '==', condition.categoryId) as any;
      }

      if (condition.isPublished !== undefined) {
        query = query.where('isPublished', '==', condition.isPublished) as any;
      }

      if (condition.difficulty) {
        query = query.where('difficulty', '==', condition.difficulty) as any;
      }

      const snapshot = await query.get();

      return snapshot.docs.map(doc => {
        const data = doc.data() as QuizDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find quizzes by condition:', error);
      throw new Error('条件検索に失敗しました');
    }
  }
}

設計のポイント:

  1. 変換レイヤー

    • toDocument: ドメインモデル → Firestore
    • toDomain: Firestore → ドメインモデル
  2. エラーハンドリング

    • すべてのメソッドでtry-catchを実装
    • ユーザーフレンドリーなエラーメッセージ
  3. 型安全性

    • QuizDocument型で永続化形式を明示
    • TypeScriptの型チェックを最大限活用

カテゴリリポジトリの実装

// infrastructure/firebase/repositories/firebase-category-repository.ts

import { Category, CategoryId } from '@/domain/category/entities/category';
import { CategoryRepository } from '@/domain/category/repositories/category-repository';
import { db, COLLECTIONS } from '../config';

interface CategoryDocument {
  id: string;
  name: string;
  description?: string;
  color: string;
  order: number;
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export class FirebaseCategoryRepository implements CategoryRepository {
  private collection = db.collection(COLLECTIONS.CATEGORIES);

  private toDocument(category: Category): CategoryDocument {
    const obj = category.toObject();
    return {
      id: obj.id,
      name: obj.name,
      description: obj.description,
      color: obj.color,
      order: obj.order,
      isActive: obj.isActive,
      createdAt: obj.createdAt,
      updatedAt: obj.updatedAt,
    };
  }

  private toDomain(doc: CategoryDocument): Category {
    return Category.reconstruct({
      id: doc.id,
      name: doc.name,
      description: doc.description,
      color: doc.color,
      order: doc.order,
      isActive: doc.isActive,
      createdAt: doc.createdAt,
      updatedAt: doc.updatedAt,
    });
  }

  async save(category: Category): Promise<void> {
    try {
      const doc = this.toDocument(category);
      await this.collection.doc(category.id).set(doc);
    } catch (error) {
      console.error('Failed to save category:', error);
      throw new Error('カテゴリの保存に失敗しました');
    }
  }

  async findById(id: CategoryId): Promise<Category | null> {
    try {
      const docSnap = await this.collection.doc(id).get();
      
      if (!docSnap.exists) {
        return null;
      }

      const data = docSnap.data() as CategoryDocument;
      return this.toDomain(data);
    } catch (error) {
      console.error('Failed to find category:', error);
      throw new Error('カテゴリの取得に失敗しました');
    }
  }

  async findAll(): Promise<Category[]> {
    try {
      const snapshot = await this.collection
        .orderBy('order', 'asc')
        .get();
      
      return snapshot.docs.map(doc => {
        const data = doc.data() as CategoryDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find all categories:', error);
      throw new Error('カテゴリ一覧の取得に失敗しました');
    }
  }

  async findActive(): Promise<Category[]> {
    try {
      const snapshot = await this.collection
        .where('isActive', '==', true)
        .orderBy('order', 'asc')
        .get();
      
      return snapshot.docs.map(doc => {
        const data = doc.data() as CategoryDocument;
        return this.toDomain(data);
      });
    } catch (error) {
      console.error('Failed to find active categories:', error);
      throw new Error('アクティブカテゴリの取得に失敗しました');
    }
  }

  async delete(id: CategoryId): Promise<void> {
    try {
      await this.collection.doc(id).delete();
    } catch (error) {
      console.error('Failed to delete category:', error);
      throw new Error('カテゴリの削除に失敗しました');
    }
  }
}

エラーハンドリング

カスタムエラークラスを定義して、エラーの種類を明確にします。

// infrastructure/errors/app-error.ts

/**
 * ドメインエラー
 * ビジネスルール違反を表す
 */
export class DomainError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'DomainError';
  }
}

/**
 * インフラストラクチャエラー
 * 技術的な問題(DB接続エラー等)を表す
 */
export class InfrastructureError extends Error {
  constructor(
    message: string,
    public readonly cause?: unknown
  ) {
    super(message);
    this.name = 'InfrastructureError';
  }
}

/**
 * 認証エラー
 */
export class AuthenticationError extends Error {
  constructor(message: string = '認証が必要です') {
    super(message);
    this.name = 'AuthenticationError';
  }
}

/**
 * 認可エラー
 */
export class AuthorizationError extends Error {
  constructor(message: string = '権限がありません') {
    super(message);
    this.name = 'AuthorizationError';
  }
}

/**
 * バリデーションエラー
 */
export class ValidationError extends Error {
  constructor(
    message: string,
    public readonly errors?: Record<string, string[]>
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

Application層の実装

Application層は、ユースケースを実装する層です。Next.js 15のServer Actionsを活用します。

Zodによるバリデーション

入力データの形式チェックはZodで行います。

クイズスキーマ

// features/quiz/schemas/quiz-schema.ts

import { z } from 'zod';

/**
 * 質問スキーマ
 */
export const questionSchema = z.object({
  text: z
    .string()
    .min(5, '質問文は5文字以上で入力してください')
    .max(500, '質問文は500文字以内で入力してください'),
  choices: z
    .array(z.string().min(1, '選択肢を入力してください'))
    .min(2, '選択肢は最低2つ必要です')
    .max(6, '選択肢は6つ以下にしてください'),
  correctAnswerIndex: z
    .number()
    .int()
    .min(0, '正解を選択してください'),
  explanation: z
    .string()
    .max(1000, '解説は1000文字以内で入力してください')
    .optional(),
});

/**
 * クイズ作成スキーマ
 */
export const createQuizSchema = z.object({
  title: z
    .string()
    .min(3, 'タイトルは3文字以上で入力してください')
    .max(100, 'タイトルは100文字以内で入力してください'),
  categoryId: z
    .string()
    .min(1, 'カテゴリを選択してください'),
  difficulty: z.enum(['easy', 'medium', 'hard'], {
    required_error: '難易度を選択してください',
  }),
  questions: z
    .array(questionSchema)
    .min(1, '最低1つの質問を追加してください')
    .max(50, '質問は50問以下にしてください'),
});

/**
 * クイズ更新スキーマ
 */
export const updateQuizSchema = createQuizSchema.extend({
  id: z.string(),
});

/**
 * 型推論
 */
export type CreateQuizInput = z.infer<typeof createQuizSchema>;
export type UpdateQuizInput = z.infer<typeof updateQuizSchema>;
export type QuestionInput = z.infer<typeof questionSchema>;

カテゴリスキーマ

// features/category/schemas/category-schema.ts

import { z } from 'zod';

export const createCategorySchema = z.object({
  name: z
    .string()
    .min(2, 'カテゴリ名は2文字以上で入力してください')
    .max(50, 'カテゴリ名は50文字以内で入力してください'),
  description: z
    .string()
    .max(200, '説明は200文字以内で入力してください')
    .optional(),
  color: z
    .string()
    .min(1, '色を選択してください'),
  order: z
    .number()
    .int()
    .min(0, '表示順序は0以上である必要があります'),
});

export const updateCategorySchema = createCategorySchema.extend({
  id: z.string(),
});

export type CreateCategoryInput = z.infer<typeof createCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof updateCategorySchema>;

Server Actionsの実装

'use server'ディレクティブの重要性

'use server';  // ← このディレクティブが必要

なぜ必要なのか:

  1. 実行環境の明示

    • サーバーサイドでのみ実行されることを保証
    • クライアントバンドルに含まれない
  2. セキュリティの確保

    • データベースアクセスコードがクライアントに露出しない
    • 環境変数へのアクセスが安全
  3. Next.jsの最適化

    • RPCエンドポイントとして自動変換
    • 型安全なまま関数呼び出しが可能

クイズ作成Action

// features/quiz/actions/create-quiz.ts

'use server';

import { revalidatePath } from 'next/cache';
import { Quiz } from '@/domain/quiz/entities/quiz';
import { Question } from '@/domain/quiz/entities/question';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
import { createQuizSchema } from '../schemas/quiz-schema';
import { DomainError } from '@/infrastructure/errors/app-error';

/**
 * 型安全な戻り値
 * 成功・失敗を判定可能な型
 */
type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

/**
 * クイズ作成
 */
export async function createQuizJson(
  data: unknown
): Promise<ActionResult<{ id: string }>> {
  try {
    // 1. Zodでバリデーション
    const validated = createQuizSchema.parse(data);

    // 2. ドメインオブジェクトの生成
    const questions = validated.questions.map(q =>
      Question.create({
        text: q.text,
        choices: q.choices,
        correctAnswerIndex: q.correctAnswerIndex,
        explanation: q.explanation,
      })
    );

    const quiz = Quiz.create({
      title: validated.title,
      categoryId: validated.categoryId,
      difficulty: validated.difficulty,
      questions,
    });

    // 3. リポジトリを通じて永続化
    const repository = new FirebaseQuizRepository();
    await repository.save(quiz);

    // 4. Next.jsキャッシュの再検証
    revalidatePath('/quizzes');

    return {
      success: true,
      data: { id: quiz.id },
    };
  } catch (error) {
    console.error('Quiz creation error:', error);

    // エラーの種類に応じた処理
    if (error instanceof DomainError) {
      return {
        success: false,
        error: error.message,
      };
    }

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: 'クイズの作成に失敗しました',
    };
  }
}

二重バリデーションの意義:

ユーザー入力
    ↓
Zodバリデーション(型・形式チェック)
    ↓
ドメインエンティティ(ビジネスルールチェック)
    ↓
永続化
  • Zod: 基本的な形式、長さ、必須項目
  • Domain: ビジネスルール(例: 公開には3問以上必要)

クイズ更新Action

// features/quiz/actions/update-quiz.ts

'use server';

import { revalidatePath } from 'next/cache';
import { Quiz } from '@/domain/quiz/entities/quiz';
import { Question } from '@/domain/quiz/entities/question';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
import { updateQuizSchema } from '../schemas/quiz-schema';

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

export async function updateQuizJson(
  data: unknown
): Promise<ActionResult<{ id: string }>> {
  try {
    const validated = updateQuizSchema.parse(data);
    const repository = new FirebaseQuizRepository();

    // 既存のクイズを取得
    const existingQuiz = await repository.findById(validated.id);
    
    if (!existingQuiz) {
      return {
        success: false,
        error: 'クイズが見つかりません',
      };
    }

    // 新しい質問リストを生成
    const questions = validated.questions.map(q =>
      Question.create({
        text: q.text,
        choices: q.choices,
        correctAnswerIndex: q.correctAnswerIndex,
        explanation: q.explanation,
      })
    );

    // ドメインオブジェクトを更新
    let updatedQuiz = existingQuiz;
    
    if (existingQuiz.title !== validated.title) {
      updatedQuiz = updatedQuiz.changeTitle(validated.title);
    }
    
    if (existingQuiz.categoryId !== validated.categoryId) {
      updatedQuiz = updatedQuiz.changeCategory(validated.categoryId);
    }
    
    if (existingQuiz.difficulty !== validated.difficulty) {
      updatedQuiz = updatedQuiz.changeDifficulty(validated.difficulty);
    }

    // 質問を全て置き換え(簡易実装)
    updatedQuiz = Quiz.reconstruct({
      ...updatedQuiz.toObject(),
      questions: questions.map(q => q.toObject()),
    });

    await repository.save(updatedQuiz);
    
    // 複数のパスを再検証
    revalidatePath('/quizzes');
    revalidatePath(`/quizzes/${validated.id}/edit`);

    return {
      success: true,
      data: { id: updatedQuiz.id },
    };
  } catch (error) {
    console.error('Quiz update error:', error);

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: 'クイズの更新に失敗しました',
    };
  }
}

クイズ公開Action

// features/quiz/actions/publish-quiz.ts

'use server';

import { revalidatePath } from 'next/cache';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

/**
 * クイズ公開
 */
export async function publishQuiz(
  id: string
): Promise<ActionResult<{ id: string }>> {
  try {
    const repository = new FirebaseQuizRepository();
    const quiz = await repository.findById(id);

    if (!quiz) {
      return {
        success: false,
        error: 'クイズが見つかりません',
      };
    }

    // ドメインロジックで公開処理
    // ここでビジネスルール(3問以上必要等)が検証される
    const publishedQuiz = quiz.publish();

    await repository.save(publishedQuiz);
    revalidatePath('/quizzes');

    return {
      success: true,
      data: { id: publishedQuiz.id },
    };
  } catch (error) {
    console.error('Quiz publish error:', error);

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: '公開に失敗しました',
    };
  }
}

/**
 * クイズ非公開化
 */
export async function unpublishQuiz(
  id: string
): Promise<ActionResult<{ id: string }>> {
  try {
    const repository = new FirebaseQuizRepository();
    const quiz = await repository.findById(id);

    if (!quiz) {
      return {
        success: false,
        error: 'クイズが見つかりません',
      };
    }

    const unpublishedQuiz = quiz.unpublish();

    await repository.save(unpublishedQuiz);
    revalidatePath('/quizzes');

    return {
      success: true,
      data: { id: unpublishedQuiz.id },
    };
  } catch (error) {
    console.error('Quiz unpublish error:', error);

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: '非公開化に失敗しました',
    };
  }
}

クイズ削除Action

// features/quiz/actions/delete-quiz.ts

'use server';

import { revalidatePath } from 'next/cache';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

export async function deleteQuiz(
  id: string
): Promise<ActionResult<{ id: string }>> {
  try {
    const repository = new FirebaseQuizRepository();
    
    // 存在確認
    const quiz = await repository.findById(id);
    if (!quiz) {
      return {
        success: false,
        error: 'クイズが見つかりません',
      };
    }

    // 公開済みのクイズは削除できない(ビジネスルール)
    if (quiz.isPublished) {
      return {
        success: false,
        error: '公開中のクイズは削除できません。先に非公開にしてください。',
      };
    }

    await repository.delete(id);
    revalidatePath('/quizzes');

    return {
      success: true,
      data: { id },
    };
  } catch (error) {
    console.error('Quiz delete error:', error);

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: 'クイズの削除に失敗しました',
    };
  }
}

DTOパターンの活用

Server ComponentsではDateオブジェクトを直接渡せないため、DTOに変換します。

クイズ取得Actions

// features/quiz/actions/get-quizzes.ts

'use server';

import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
import { Quiz } from '@/domain/quiz/entities/quiz';

/**
 * クイズDTO(Data Transfer Object)
 * ドメインオブジェクトをシリアライズ可能な形式に変換
 */
export interface QuizDTO {
  id: string;
  title: string;
  categoryId: string;
  difficulty: 'easy' | 'medium' | 'hard';
  questionCount: number;
  isPublished: boolean;
  createdAt: string; // ISO 8601文字列
  updatedAt: string;
}

/**
 * 詳細なクイズDTO(編集画面用)
 */
export interface QuizDetailDTO extends QuizDTO {
  questions: {
    text: string;
    choices: string[];
    correctAnswerIndex: number;
    explanation?: string;
  }[];
}

/**
 * ドメインモデル→DTO変換
 */
function toDTO(quiz: Quiz): QuizDTO {
  return {
    id: quiz.id,
    title: quiz.title,
    categoryId: quiz.categoryId,
    difficulty: quiz.difficulty,
    questionCount: quiz.questions.length,
    isPublished: quiz.isPublished,
    createdAt: quiz.createdAt.toISOString(),
    updatedAt: quiz.updatedAt.toISOString(),
  };
}

function toDetailDTO(quiz: Quiz): QuizDetailDTO {
  return {
    ...toDTO(quiz),
    questions: quiz.questions.map(q => ({
      text: q.text,
      choices: q.choices,
      correctAnswerIndex: q.correctAnswerIndex,
      explanation: q.explanation,
    })),
  };
}

/**
 * すべてのクイズを取得
 */
export async function getQuizzes(): Promise<QuizDTO[]> {
  try {
    const repository = new FirebaseQuizRepository();
    const quizzes = await repository.findAll();
    return quizzes.map(toDTO);
  } catch (error) {
    console.error('Failed to fetch quizzes:', error);
    throw new Error('クイズの取得に失敗しました');
  }
}

/**
 * IDでクイズを取得
 */
export async function getQuizById(id: string): Promise<QuizDetailDTO | null> {
  try {
    const repository = new FirebaseQuizRepository();
    const quiz = await repository.findById(id);
    return quiz ? toDetailDTO(quiz) : null;
  } catch (error) {
    console.error('Failed to fetch quiz:', error);
    throw new Error('クイズの取得に失敗しました');
  }
}

/**
 * カテゴリIDでクイズを取得
 */
export async function getQuizzesByCategory(
  categoryId: string
): Promise<QuizDTO[]> {
  try {
    const repository = new FirebaseQuizRepository();
    const quizzes = await repository.findByCategoryId(categoryId);
    return quizzes.map(toDTO);
  } catch (error) {
    console.error('Failed to fetch quizzes by category:', error);
    throw new Error('クイズの取得に失敗しました');
  }
}

/**
 * 公開済みクイズを取得
 */
export async function getPublishedQuizzes(): Promise<QuizDTO[]> {
  try {
    const repository = new FirebaseQuizRepository();
    const quizzes = await repository.findPublished();
    return quizzes.map(toDTO);
  } catch (error) {
    console.error('Failed to fetch published quizzes:', error);
    throw new Error('公開済みクイズの取得に失敗しました');
  }
}

DTOのメリット:

  1. シリアライズの問題を回避

    • DateオブジェクトをISO文字列に変換
    • Server Componentsで安全に使用可能
  2. 必要な情報のみを返す

    • 一覧表示ではquestionCountのみ
    • 詳細表示ではquestions配列も含む
  3. ドメインモデルの内部構造を隠蔽

    • クライアントは実装詳細を知らない

カテゴリ取得Actions

// features/category/actions/get-categories.ts

'use server';

import { FirebaseCategoryRepository } from '@/infrastructure/firebase/repositories/firebase-category-repository';

export interface CategoryDTO {
  id: string;
  name: string;
  description?: string;
  color: string;
  order: number;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

export async function getCategories(): Promise<CategoryDTO[]> {
  try {
    const repository = new FirebaseCategoryRepository();
    const categories = await repository.findAll();
    
    return categories.map(cat => ({
      id: cat.id,
      name: cat.name,
      description: cat.description,
      color: cat.color,
      order: cat.order,
      isActive: cat.isActive,
      createdAt: cat.createdAt.toISOString(),
      updatedAt: cat.updatedAt.toISOString(),
    }));
  } catch (error) {
    console.error('Failed to fetch categories:', error);
    throw new Error('カテゴリの取得に失敗しました');
  }
}

export async function getActiveCategories(): Promise<CategoryDTO[]> {
  try {
    const repository = new FirebaseCategoryRepository();
    const categories = await repository.findActive();
    
    return categories.map(cat => ({
      id: cat.id,
      name: cat.name,
      description: cat.description,
      color: cat.color,
      order: cat.order,
      isActive: cat.isActive,
      createdAt: cat.createdAt.toISOString(),
      updatedAt: cat.updatedAt.toISOString(),
    }));
  } catch (error) {
    console.error('Failed to fetch active categories:', error);
    throw new Error('カテゴリの取得に失敗しました');
  }
}

カテゴリ作成・更新Actions

// features/category/actions/create-category.ts

'use server';

import { revalidatePath } from 'next/cache';
import { Category } from '@/domain/category/entities/category';
import { FirebaseCategoryRepository } from '@/infrastructure/firebase/repositories/firebase-category-repository';
import { createCategorySchema } from '../schemas/category-schema';

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

export async function createCategory(
  data: unknown
): Promise<ActionResult<{ id: string }>> {
  try {
    const validated = createCategorySchema.parse(data);

    const category = Category.create({
      name: validated.name,
      description: validated.description,
      color: validated.color,
      order: validated.order,
    });

    const repository = new FirebaseCategoryRepository();
    await repository.save(category);

    revalidatePath('/categories');

    return {
      success: true,
      data: { id: category.id },
    };
  } catch (error) {
    console.error('Category creation error:', error);

    if (error instanceof Error) {
      return {
        success: false,
        error: error.message,
      };
    }

    return {
      success: false,
      error: 'カテゴリの作成に失敗しました',
    };
  }
}

ベストプラクティス

1. エラーハンドリングの3層構造

// Domain層: ビジネスルール違反
class Quiz {
  publish(): Quiz {
    if (this.questions.length < 3) {
      throw new DomainError('公開には最低3問必要');
    }
  }
}

// Infrastructure層: 技術的エラー
class FirebaseQuizRepository {
  async save(quiz: Quiz): Promise<void> {
    try {
      await db.collection('quizzes').doc(quiz.id).set(...);
    } catch (error) {
      throw new InfrastructureError('保存に失敗しました', error);
    }
  }
}

// Application層: エラーを集約して返す
async function createQuiz(data: unknown) {
  try {
    // 処理
  } catch (error) {
    if (error instanceof DomainError) {
      return { success: false, error: error.message };
    }
    if (error instanceof InfrastructureError) {
      return { success: false, error: '保存に失敗しました' };
    }
    return { success: false, error: '予期しないエラー' };
  }
}

2. revalidatePathの活用

// 単一パスの再検証
revalidatePath('/quizzes');

// 複数パスの再検証
revalidatePath('/quizzes');
revalidatePath(`/quizzes/${id}/edit`);

// パターンマッチング
revalidatePath('/quizzes/[id]', 'page');

3. 型安全な戻り値

// ✅ 良い例: 判別可能なユニオン型
type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

// 使用側
const result = await createQuiz(data);
if (result.success) {
  console.log(result.data.id); // 型安全
} else {
  console.error(result.error); // 型安全
}

// ❌ 悪い例: any型
async function createQuiz(data: any): Promise<any> {
  // 型安全性がない
}

4. リポジトリのDI(依存性注入)

将来的な拡張を考慮した設計:

// Application層でリポジトリを注入可能に
export async function createQuizWithRepository(
  data: unknown,
  repository: QuizRepository = new FirebaseQuizRepository()
): Promise<ActionResult<{ id: string }>> {
  // テスト時はモックリポジトリを注入可能
}

まとめ

第3部では、Infrastructure層とApplication層の実装について解説しました。

重要なポイント

Infrastructure層

  • リポジトリパターンの実装

    • ドメインモデル⇔永続化形式の変換
    • エラーハンドリングの徹底
  • Firebaseとの連携

    • Admin SDKの適切な初期化
    • コレクション名の一元管理

Application層

  • Server Actionsの設計

    • 'use server'ディレクティブの重要性
    • 型安全な戻り値
  • 二重バリデーション

    • Zod: 形式チェック
    • Domain: ビジネスルールチェック
  • DTOパターン

    • シリアライズ可能な形式に変換
    • 必要な情報のみを返す

次回予告

【第4部: Presentation層編】 では、最終回として以下を解説します:

  • Server/Client Componentの使い分け
  • フォーム実装(React Hook Form)
  • UI設計とshadcn/ui活用
  • パフォーマンス最適化
  • 実践的なTips

Domain、Infrastructure、Applicationで作った機能を、実際のUIで動かしていきます!


参考資料

この記事が役に立ったら、いいねやコメントをいただけると嬉しいです!
次回【第4部: Presentation層編】もお楽しみに!

Discussion