フロントエンドのための Clean Architecture¶
はじめに¶
Clean Architectureは、関心の分離とフレームワーク、UI、外部システムからの独立性を重視するソフトウェア設計哲学です。フロントエンド開発において、保守可能でテスト可能なアプリケーションを構築するための体系的なアプローチを提供します。
核となる原則¶
1. 独立性¶
- フレームワークからの独立性: ビジネスロジックはReact、Vue、その他のフレームワークに依存しない
- UIからの独立性: ドメインロジックはUI実装に関係なく動作する
- データベースからの独立性: データ層はビジネスルールに影響を与えることなく交換可能
- 外部機関からの独立性: 外部システムがアーキテクチャを決定しない
2. 依存性ルール¶
依存性は内側を向く: 外側の層は内側の層に依存できますが、その逆は決してありません。
┌─────────────────────────────────────────┐
│ Infrastructure (External) │ ← フレームワーク、API、DB
├─────────────────────────────────────────┤
│ Interface Adapters (Glue) │ ← コントローラー、プレゼンター
├─────────────────────────────────────────┤
│ Application Business Rules (UseCases)│ ← アプリケーションロジック
├─────────────────────────────────────────┤
│ Enterprise Business Rules (Entities) │ ← コアドメイン
└─────────────────────────────────────────┘
フロントエンドにおける層¶
第1層: Entities (Domain)¶
純粋なビジネスオブジェクト、依存性なし。
// core/domain/user.ts
export class User {
constructor(
public readonly id: string,
public readonly name: string,
public readonly email: string
) {
this.validate();
}
private validate(): void {
if (!this.email.includes('@')) {
throw new Error('Invalid email');
}
if (this.name.length < 2) {
throw new Error('Name too short');
}
}
canAccessAdminPanel(): boolean {
return this.email.endsWith('@company.com');
}
}
特徴: - 外部依存性なし - ビジネスルールを含む - フレームワーク非依存 - 容易にテスト可能
第2層: Use Cases (Application)¶
ドメインエンティティをオーケストレーションするアプリケーション固有のビジネスルール。
// core/usecases/registerUser.ts
export interface RegisterUserInput {
name: string;
email: string;
password: string;
}
export interface UserRepository {
save(user: User): Promise<void>;
findByEmail(email: string): Promise<User | null>;
}
export class RegisterUserUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private passwordHasher: PasswordHasher
) {}
async execute(input: RegisterUserInput): Promise<User> {
// 1. ビジネスルールの検証
const existingUser = await this.userRepository.findByEmail(input.email);
if (existingUser) {
throw new Error('Email already registered');
}
// 2. ドメインエンティティの作成
const user = new User(
generateId(),
input.name,
input.email
);
// 3. パスワードのハッシュ化(インフラストラクチャの関心事)
const hashedPassword = await this.passwordHasher.hash(input.password);
// 4. 永続化
await this.userRepository.save(user);
// 5. 通知の送信
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
特徴: - ドメインとインターフェースのみに依存 - ビジネス操作をオーケストレーション - UIやフレームワークのコードなし - 純粋なTypeScript/JavaScript
第3層: Interface Adapters¶
Use casesと外部システム間でデータを変換します。
Controllers (Input)¶
// adapters/controllers/userController.ts
export class UserController {
constructor(private registerUseCase: RegisterUserUseCase) {}
async handleRegistration(request: Request): Promise<Response> {
try {
const { name, email, password } = await request.json();
const user = await this.registerUseCase.execute({
name,
email,
password
});
return Response.json({
success: true,
userId: user.id
}, { status: 201 });
} catch (error) {
return Response.json({
success: false,
error: error.message
}, { status: 400 });
}
}
}
Presenters (Output)¶
// adapters/presenters/userPresenter.ts
export class UserPresenter {
present(user: User): UserViewModel {
return {
id: user.id,
displayName: user.name,
email: user.email,
isAdmin: user.canAccessAdminPanel()
};
}
presentList(users: User[]): UserListViewModel {
return {
users: users.map(u => this.present(u)),
total: users.length
};
}
}
export interface UserViewModel {
id: string;
displayName: string;
email: string;
isAdmin: boolean;
}
第4層: Infrastructure¶
外部システムとフレームワーク: データベース、API、UIフレームワークなど。
Repositoryの実装¶
// infrastructure/repositories/apiUserRepository.ts
import { User } from '@/core/domain/user';
import { UserRepository } from '@/core/usecases/registerUser';
export class ApiUserRepository implements UserRepository {
constructor(private apiClient: ApiClient) {}
async save(user: User): Promise<void> {
await this.apiClient.post('/users', {
id: user.id,
name: user.name,
email: user.email
});
}
async findByEmail(email: string): Promise<User | null> {
const response = await this.apiClient.get(`/users?email=${email}`);
if (!response.data) return null;
return new User(
response.data.id,
response.data.name,
response.data.email
);
}
}
UIコンポーネント (React)¶
// infrastructure/ui/components/RegisterForm.tsx
'use client';
import { useState } from 'react';
import { registerUserAction } from '@/infrastructure/ui/actions/userActions';
export function RegisterForm() {
const [error, setError] = useState('');
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const result = await registerUserAction(formData);
if (!result.success) {
setError(result.error);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Register</button>
{error && <p className="error">{error}</p>}
</form>
);
}
Next.js Clean Architecture構造¶
推奨フォルダ構造¶
src/
├── app/ # Next.js App Router
│ ├── (auth)/
│ │ └── register/
│ │ └── page.tsx # UIエントリーポイント
│ └── api/
│ └── users/
│ └── route.ts # APIルートハンドラー
│
├── core/ # ビジネスロジック(フレームワーク非依存)
│ ├── domain/ # エンティティ
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── order.ts
│ ├── usecases/ # アプリケーションロジック
│ │ ├── registerUser.ts
│ │ ├── createOrder.ts
│ │ └── interfaces/ # Use Caseインターフェース
│ │ ├── repositories.ts
│ │ └── services.ts
│ └── common/ # 共有ドメインロジック
│ ├── errors.ts
│ └── types.ts
│
├── infrastructure/ # 外部システムとフレームワーク
│ ├── repositories/
│ │ ├── apiUserRepository.ts
│ │ ├── localStorageRepository.ts
│ │ └── mockRepository.ts
│ ├── services/
│ │ ├── emailService.ts
│ │ └── authService.ts
│ ├── api/
│ │ └── client.ts
│ └── ui/ # React/Next.js固有
│ ├── components/
│ ├── hooks/
│ └── actions/ # Server Actions
│
├── adapters/ # Interface Adapters
│ ├── controllers/
│ ├── presenters/
│ └── viewModels/
│
└── config/ # 設定
├── dependencies.ts # Dependency Injection
└── constants.ts
Dependency Injection¶
シンプルなDIコンテナ¶
// config/dependencies.ts
import { RegisterUserUseCase } from '@/core/usecases/registerUser';
import { ApiUserRepository } from '@/infrastructure/repositories/apiUserRepository';
import { EmailService } from '@/infrastructure/services/emailService';
import { PasswordHasher } from '@/infrastructure/services/passwordHasher';
import { ApiClient } from '@/infrastructure/api/client';
class DependencyContainer {
private static instance: DependencyContainer;
private _apiClient?: ApiClient;
private _userRepository?: ApiUserRepository;
private _emailService?: EmailService;
private _passwordHasher?: PasswordHasher;
static getInstance(): DependencyContainer {
if (!DependencyContainer.instance) {
DependencyContainer.instance = new DependencyContainer();
}
return DependencyContainer.instance;
}
get apiClient(): ApiClient {
if (!this._apiClient) {
this._apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL!);
}
return this._apiClient;
}
get userRepository(): ApiUserRepository {
if (!this._userRepository) {
this._userRepository = new ApiUserRepository(this.apiClient);
}
return this._userRepository;
}
get emailService(): EmailService {
if (!this._emailService) {
this._emailService = new EmailService();
}
return this._emailService;
}
get passwordHasher(): PasswordHasher {
if (!this._passwordHasher) {
this._passwordHasher = new PasswordHasher();
}
return this._passwordHasher;
}
getRegisterUserUseCase(): RegisterUserUseCase {
return new RegisterUserUseCase(
this.userRepository,
this.emailService,
this.passwordHasher
);
}
}
export const container = DependencyContainer.getInstance();
Server Actionsでの使用¶
// infrastructure/ui/actions/userActions.ts
'use server';
import { container } from '@/config/dependencies';
import { revalidatePath } from 'next/cache';
export async function registerUserAction(formData: FormData) {
const useCase = container.getRegisterUserUseCase();
try {
const user = await useCase.execute({
name: formData.get('name') as string,
email: formData.get('email') as string,
password: formData.get('password') as string
});
revalidatePath('/users');
return { success: true, userId: user.id };
} catch (error) {
return { success: false, error: error.message };
}
}
Clean Architecture with RSC + Server Actions¶
Server Components (Query/Read)¶
// app/products/page.tsx (Server Component)
import { container } from '@/config/dependencies';
import { ProductCard } from '@/infrastructure/ui/components/ProductCard';
export default async function ProductsPage() {
// サーバーでuse caseを実行
const getProducts = container.getGetProductsUseCase();
const products = await getProducts.execute();
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Server Actions (Command/Write)¶
// infrastructure/ui/actions/productActions.ts
'use server';
import { container } from '@/config/dependencies';
import { revalidatePath } from 'next/cache';
export async function createProductAction(formData: FormData) {
const useCase = container.getCreateProductUseCase();
const result = await useCase.execute({
name: formData.get('name') as string,
price: Number(formData.get('price')),
description: formData.get('description') as string
});
revalidatePath('/products');
return result;
}
Client Components (Interactive UI)¶
// infrastructure/ui/components/CreateProductForm.tsx
'use client';
import { useFormState } from 'react-dom';
import { createProductAction } from '../actions/productActions';
export function CreateProductForm() {
const [state, formAction] = useFormState(createProductAction, null);
return (
<form action={formAction}>
<input name="name" placeholder="Product Name" required />
<input name="price" type="number" placeholder="Price" required />
<textarea name="description" placeholder="Description" />
<button type="submit">Create Product</button>
{state?.success && <p>Product created!</p>}
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}
Clean Architectureのテスト¶
ドメイン層のテスト(純粋)¶
// core/domain/user.test.ts
import { User } from './user';
describe('User', () => {
it('creates valid user', () => {
const user = new User('1', 'John Doe', 'john@example.com');
expect(user.name).toBe('John Doe');
});
it('throws error for invalid email', () => {
expect(() => new User('1', 'John', 'invalid-email'))
.toThrow('Invalid email');
});
it('identifies admin users', () => {
const admin = new User('1', 'Admin', 'admin@company.com');
const regular = new User('2', 'User', 'user@gmail.com');
expect(admin.canAccessAdminPanel()).toBe(true);
expect(regular.canAccessAdminPanel()).toBe(false);
});
});
Use Caseのテスト(モックあり)¶
// core/usecases/registerUser.test.ts
import { RegisterUserUseCase } from './registerUser';
import { MockUserRepository } from '@/infrastructure/repositories/mockRepository';
import { MockEmailService } from '@/infrastructure/services/mockEmailService';
import { MockPasswordHasher } from '@/infrastructure/services/mockPasswordHasher';
describe('RegisterUserUseCase', () => {
let useCase: RegisterUserUseCase;
let mockRepo: MockUserRepository;
let mockEmail: MockEmailService;
let mockHasher: MockPasswordHasher;
beforeEach(() => {
mockRepo = new MockUserRepository();
mockEmail = new MockEmailService();
mockHasher = new MockPasswordHasher();
useCase = new RegisterUserUseCase(mockRepo, mockEmail, mockHasher);
});
it('registers new user successfully', async () => {
const input = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
const user = await useCase.execute(input);
expect(user.name).toBe('John Doe');
expect(mockRepo.users).toHaveLength(1);
expect(mockEmail.sentEmails).toHaveLength(1);
});
it('throws error when email already exists', async () => {
mockRepo.users.push(new User('1', 'Existing', 'john@example.com'));
await expect(useCase.execute({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
})).rejects.toThrow('Email already registered');
});
});
Clean Architectureの利点¶
1. テスタビリティ¶
// UIフレームワークなしで簡単にテスト可能
const useCase = new CreateOrderUseCase(mockRepository);
const result = await useCase.execute(input);
expect(result.totalPrice).toBe(100);
2. 柔軟性¶
ビジネスロジックを変更することなく実装を交換可能:
// 開発環境: Mockリポジトリ
const userRepo = new MockUserRepository();
// 本番環境: APIリポジトリ
const userRepo = new ApiUserRepository(apiClient);
// 同じuse caseが両方で動作
const useCase = new RegisterUserUseCase(userRepo, emailService, hasher);
3. フレームワーク非依存性¶
// ビジネスロジックはReact、Next.js、その他のフレームワークを知らない
export class CalculateShippingCost {
execute(weight: number, distance: number): number {
return (weight * 0.5) + (distance * 0.1);
}
}
// どこでも使用可能: React、Vue、Node.js、Denoなど
よくある間違い¶
❌ 層の混在¶
// やってはいけない: ドメイン層にReact hooks
export class User {
async save() {
const [loading, setLoading] = useState(false); // ❌ 間違い!
await fetch('/api/users', { ... });
}
}
❌ フレームワークへの直接依存¶
// やってはいけない: Use CaseにNext.js固有のコード
export class LoginUseCase {
async execute(email: string, password: string) {
redirect('/dashboard'); // ❌ 間違い! Next.js依存
}
}
❌ Use Casesのバイパス¶
// やってはいけない: UIからリポジトリに直接アクセス
'use client';
export function UserList() {
const users = await userRepository.findAll(); // ❌ 間違い!
return <div>{users.map(...)}</div>;
}
// すべき: Use Caseのオーケストレーション
export function UserList() {
const getUsers = container.getGetUsersUseCase();
const users = await getUsers.execute();
return <div>{users.map(...)}</div>;
}
Clean Architectureを使用するタイミング¶
使用すべき場合:¶
- 中〜大規模アプリケーション
- 長期的なメンテナンスが予想される
- 複数の開発者/チーム
- 複雑なビジネスロジック
- 実装の交換が必要(APIプロバイダー、データベース)
- 高いテスタビリティが必要
スキップすべき場合:¶
- シンプルなランディングページ
- 厳しい期限のプロトタイプ/MVP
- 非常に小規模なアプリケーション(10ページ未満)
- チームがパターンに不慣れ(学習曲線が急すぎる)
段階的な採用¶
Clean Architectureを一度にすべて採用する必要はありません:
- Use Casesから開始 - ビジネスロジックを関数に抽出
- Repository Patternの追加 - API呼び出しを抽象化
- Domain Entitiesの導入 - 検証をドメインオブジェクトに移動
- DIの実装 - 柔軟性のためにdependency injectionを追加
- 層の洗練 - 徐々に層の境界を強制
リソース¶
- Clean Architecture by Robert C. Martin
- Frontend Clean Architecture Examples
- The Clean Architecture in TypeScript
まとめ¶
フロントエンドにおけるClean Architecture: - 関心の分離: 各層は単一の責任を持つ - 依存性ルール: 内側の層は外側の層を知らない - フレームワーク非依存性: ビジネスロジックは純粋なTypeScript - テスタビリティ: UIや外部依存なしで簡単にテスト可能 - 柔軟性: 実装の交換が容易
プロジェクトのニーズに基づいて選択的にClean Architectureを適用してください。目標は、パターンへの教条的な固執ではなく、保守可能でテスト可能なコードです。