はじめに
Clean Architectureは、Robert C. Martin(Uncle Bob)が提唱したソフトウェア設計の原則です。この記事では、Next.jsプロジェクトにClean Architectureを適用し、保守性が高くテスト可能なアプリケーションを構築する方法を解説します。
Clean Architectureの概念
Clean Architectureの核心は「依存関係の方向」です。外側の層が内側の層に依存し、その逆は許容されません。
┌────────────────────────────────────┐
│ Infrastructure │ 外側(フレームワーク、DB)
│ ┌────────────────────────────┐ │
│ │ Application │ │ ユースケース
│ │ ┌────────────────────┐ │ │
│ │ │ Domain │ │ │ 内側(ビジネスロジック)
│ │ │ (Entities) │ │ │
│ │ └────────────────────┘ │ │
│ └────────────────────────────┘ │
└────────────────────────────────────┘
Next.jsでのフォルダ構成
src/
├── domain/ # ドメイン層(ビジネスロジック)
│ ├── entities/ # エンティティ(ビジネスオブジェクト)
│ │ ├── User.ts
│ │ └── Post.ts
│ ├── repositories/ # リポジトリインターフェース
│ │ ├── IUserRepository.ts
│ │ └── IPostRepository.ts
│ └── services/ # ドメインサービス
│ └── AuthService.ts
│
├── application/ # アプリケーション層(ユースケース)
│ ├── usecases/
│ │ ├── user/
│ │ │ ├── CreateUserUseCase.ts
│ │ │ ├── GetUserUseCase.ts
│ │ │ └── UpdateUserUseCase.ts
│ │ └── post/
│ │ ├── CreatePostUseCase.ts
│ │ └── GetPostsUseCase.ts
│ └── dto/ # データ転送オブジェクト
│ ├── CreateUserDTO.ts
│ └── UserResponseDTO.ts
│
├── infrastructure/ # インフラ層(外部依存)
│ ├── repositories/ # リポジトリ実装
│ │ ├── PrismaUserRepository.ts
│ │ └── PrismaPostRepository.ts
│ ├── database/
│ │ └── prisma.ts
│ └── external/ # 外部サービス
│ └── EmailService.ts
│
├── presentation/ # プレゼンテーション層(UI)
│ ├── components/
│ ├── hooks/
│ └── contexts/
│
└── app/ # Next.js App Router
├── api/
├── (auth)/
└── (main)/
ドメイン層の実装
エンティティ
// domain/entities/User.ts
export interface UserProps {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export class User {
private constructor(private readonly props: UserProps) {}
// ファクトリメソッド
static create(props: Omit<UserProps, 'id' | 'createdAt' | 'updatedAt'>): User {
return new User({
...props,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
}
// 復元メソッド(DBからの読み込み用)
static reconstruct(props: UserProps): User {
return new User(props);
}
// ゲッター
get id(): string {
return this.props.id;
}
get email(): string {
return this.props.email;
}
get name(): string {
return this.props.name;
}
// ビジネスロジック
updateName(newName: string): User {
if (newName.length < 2) {
throw new Error('Name must be at least 2 characters');
}
return new User({
...this.props,
name: newName,
updatedAt: new Date(),
});
}
// シリアライズ
toObject(): UserProps {
return { ...this.props };
}
}
リポジトリインターフェース
// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
ドメインサービス
// domain/services/AuthService.ts
import { User } from '../entities/User';
import { IUserRepository } from '../repositories/IUserRepository';
export class AuthService {
constructor(private readonly userRepository: IUserRepository) {}
async validateEmail(email: string): Promise<boolean> {
const existingUser = await this.userRepository.findByEmail(email);
return existingUser === null;
}
async canUserPerformAction(userId: string, action: string): Promise<boolean> {
const user = await this.userRepository.findById(userId);
if (!user) return false;
// ビジネスルールに基づいた権限チェック
return true;
}
}
アプリケーション層の実装
ユースケース
// application/usecases/user/CreateUserUseCase.ts
import { User } from '@/domain/entities/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { CreateUserDTO } from '@/application/dto/CreateUserDTO';
import { UserResponseDTO } from '@/application/dto/UserResponseDTO';
export class CreateUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(dto: CreateUserDTO): Promise<UserResponseDTO> {
// バリデーション
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser) {
throw new Error('User with this email already exists');
}
// エンティティの作成
const user = User.create({
email: dto.email,
name: dto.name,
});
// 永続化
const savedUser = await this.userRepository.save(user);
// DTOに変換して返す
return {
id: savedUser.id,
email: savedUser.email,
name: savedUser.name,
};
}
}
// application/usecases/user/GetUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserResponseDTO } from '@/application/dto/UserResponseDTO';
export class GetUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(userId: string): Promise<UserResponseDTO | null> {
const user = await this.userRepository.findById(userId);
if (!user) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}
}
DTO
// application/dto/CreateUserDTO.ts
export interface CreateUserDTO {
email: string;
name: string;
}
// application/dto/UserResponseDTO.ts
export interface UserResponseDTO {
id: string;
email: string;
name: string;
}
インフラ層の実装
リポジトリ実装
// infrastructure/repositories/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { User } from '@/domain/entities/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: string): Promise<User | null> {
const userData = await this.prisma.user.findUnique({
where: { id },
});
if (!userData) return null;
return User.reconstruct({
id: userData.id,
email: userData.email,
name: userData.name,
createdAt: userData.createdAt,
updatedAt: userData.updatedAt,
});
}
async findByEmail(email: string): Promise<User | null> {
const userData = await this.prisma.user.findUnique({
where: { email },
});
if (!userData) return null;
return User.reconstruct({
id: userData.id,
email: userData.email,
name: userData.name,
createdAt: userData.createdAt,
updatedAt: userData.updatedAt,
});
}
async findAll(): Promise<User[]> {
const usersData = await this.prisma.user.findMany();
return usersData.map((userData) =>
User.reconstruct({
id: userData.id,
email: userData.email,
name: userData.name,
createdAt: userData.createdAt,
updatedAt: userData.updatedAt,
})
);
}
async save(user: User): Promise<User> {
const userData = user.toObject();
const savedUser = await this.prisma.user.upsert({
where: { id: userData.id },
update: {
email: userData.email,
name: userData.name,
updatedAt: userData.updatedAt,
},
create: {
id: userData.id,
email: userData.email,
name: userData.name,
createdAt: userData.createdAt,
updatedAt: userData.updatedAt,
},
});
return User.reconstruct({
id: savedUser.id,
email: savedUser.email,
name: savedUser.name,
createdAt: savedUser.createdAt,
updatedAt: savedUser.updatedAt,
});
}
async delete(id: string): Promise<void> {
await this.prisma.user.delete({
where: { id },
});
}
}
Prisma設定
// infrastructure/database/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
依存性注入(DI)コンテナ
// infrastructure/container.ts
import { PrismaClient } from '@prisma/client';
import { prisma } from './database/prisma';
import { PrismaUserRepository } from './repositories/PrismaUserRepository';
import { PrismaPostRepository } from './repositories/PrismaPostRepository';
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { GetUserUseCase } from '@/application/usecases/user/GetUserUseCase';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
// シンプルなDIコンテナ
class Container {
private static instance: Container;
private prismaClient: PrismaClient;
private constructor() {
this.prismaClient = prisma;
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
// リポジトリ
getUserRepository(): IUserRepository {
return new PrismaUserRepository(this.prismaClient);
}
// ユースケース
getCreateUserUseCase(): CreateUserUseCase {
return new CreateUserUseCase(this.getUserRepository());
}
getGetUserUseCase(): GetUserUseCase {
return new GetUserUseCase(this.getUserRepository());
}
}
export const container = Container.getInstance();
APIルートでの使用
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { container } from '@/infrastructure/container';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const createUserUseCase = container.getCreateUserUseCase();
const user = await createUserUseCase.execute({
email: body.email,
name: body.name,
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function GET() {
try {
const userRepository = container.getUserRepository();
const users = await userRepository.findAll();
return NextResponse.json(
users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
}))
);
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { container } from '@/infrastructure/container';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const getUserUseCase = container.getGetUserUseCase();
const user = await getUserUseCase.execute(params.id);
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
テストの実装
ユースケースのテスト
// __tests__/application/usecases/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { User } from '@/domain/entities/User';
// モックリポジトリ
class MockUserRepository implements IUserRepository {
private users: User[] = [];
async findById(id: string): Promise<User | null> {
return this.users.find((u) => u.id === id) || null;
}
async findByEmail(email: string): Promise<User | null> {
return this.users.find((u) => u.email === email) || null;
}
async findAll(): Promise<User[]> {
return this.users;
}
async save(user: User): Promise<User> {
this.users.push(user);
return user;
}
async delete(id: string): Promise<void> {
this.users = this.users.filter((u) => u.id !== id);
}
}
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockRepository: MockUserRepository;
beforeEach(() => {
mockRepository = new MockUserRepository();
useCase = new CreateUserUseCase(mockRepository);
});
it('should create a new user', async () => {
const result = await useCase.execute({
email: 'test@example.com',
name: 'Test User',
});
expect(result.email).toBe('test@example.com');
expect(result.name).toBe('Test User');
expect(result.id).toBeDefined();
});
it('should throw error if email already exists', async () => {
await useCase.execute({
email: 'test@example.com',
name: 'Test User',
});
await expect(
useCase.execute({
email: 'test@example.com',
name: 'Another User',
})
).rejects.toThrow('User with this email already exists');
});
});
エンティティのテスト
// __tests__/domain/entities/User.test.ts
import { User } from '@/domain/entities/User';
describe('User Entity', () => {
it('should create a user with valid data', () => {
const user = User.create({
email: 'test@example.com',
name: 'Test User',
});
expect(user.email).toBe('test@example.com');
expect(user.name).toBe('Test User');
expect(user.id).toBeDefined();
});
it('should update name correctly', () => {
const user = User.create({
email: 'test@example.com',
name: 'Old Name',
});
const updatedUser = user.updateName('New Name');
expect(updatedUser.name).toBe('New Name');
});
it('should throw error for invalid name', () => {
const user = User.create({
email: 'test@example.com',
name: 'Test User',
});
expect(() => user.updateName('A')).toThrow(
'Name must be at least 2 characters'
);
});
});
Server Actionsでの使用
// app/actions/user.ts
'use server';
import { container } from '@/infrastructure/container';
import { revalidatePath } from 'next/cache';
export async function createUser(formData: FormData) {
const email = formData.get('email') as string;
const name = formData.get('name') as string;
const createUserUseCase = container.getCreateUserUseCase();
try {
const user = await createUserUseCase.execute({ email, name });
revalidatePath('/users');
return { success: true, user };
} catch (error) {
if (error instanceof Error) {
return { success: false, error: error.message };
}
return { success: false, error: 'Unknown error' };
}
}
まとめ
Clean ArchitectureをNext.jsに適用するポイントをまとめます。
- レイヤー分離: ドメイン、アプリケーション、インフラ、プレゼンテーション層を明確に分離
- 依存性の逆転: インフラ層がドメイン層のインターフェースに依存
- テスト容易性: モックを使ったユニットテストが容易
- DIコンテナ: 依存関係を一元管理
- 再利用性: ビジネスロジックをUIから分離して再利用可能に