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('条件検索に失敗しました');
}
}
}
設計のポイント:
-
変換レイヤー
-
toDocument: ドメインモデル → Firestore -
toDomain: Firestore → ドメインモデル
-
-
エラーハンドリング
- すべてのメソッドでtry-catchを実装
- ユーザーフレンドリーなエラーメッセージ
-
型安全性
-
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'; // ← このディレクティブが必要
なぜ必要なのか:
-
実行環境の明示
- サーバーサイドでのみ実行されることを保証
- クライアントバンドルに含まれない
-
セキュリティの確保
- データベースアクセスコードがクライアントに露出しない
- 環境変数へのアクセスが安全
-
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のメリット:
-
シリアライズの問題を回避
- DateオブジェクトをISO文字列に変換
- Server Componentsで安全に使用可能
-
必要な情報のみを返す
- 一覧表示では
questionCountのみ - 詳細表示では
questions配列も含む
- 一覧表示では
-
ドメインモデルの内部構造を隠蔽
- クライアントは実装詳細を知らない
カテゴリ取得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