React クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離
React アプリケーションの開発において、コードの保守性や拡張性を高めるためには、適切なアーキテクチャ設計が欠かせません。特に、UI・アプリケーション・ドメイン・データといった各層の責務を明確に分離することで、テストしやすく変更に強いコードベースを構築できます。本記事では、React におけるクリーンアーキテクチャの実践方法を、具体的なコード例とともに詳しく解説します。
背景
React アプリケーションの開発では、コンポーネントにビジネスロジックやデータ取得処理を直接記述してしまうケースが多く見られます。開発初期は問題なく進みますが、アプリケーションが成長するにつれて、コードの見通しが悪くなり、テストや機能追加が困難になっていきます。
クリーンアーキテクチャは、ソフトウェアの各関心事を層として分離し、依存関係を一方向に保つことで、変更に強く保守しやすいシステムを実現する設計思想です。React においてこの考え方を適用することで、UI の変更がビジネスロジックに影響を与えず、逆にビジネスロジックの変更が UI に影響を与えないような構造を作れます。
以下の図は、クリーンアーキテクチャにおける依存関係の流れを示しています。
mermaidflowchart TD
ui["UI 層<br/>(Presentation)"]
app["アプリケーション層<br/>(Use Cases)"]
domain["ドメイン層<br/>(Entities / Business Rules)"]
data["データ層<br/>(Infrastructure)"]
ui -->|依存| app
app -->|依存| domain
data -->|実装| domain
style domain fill:#e1f5ff
style app fill:#fff4e1
style ui fill:#ffe1f5
style data fill:#e1ffe1
この図から分かるように、依存の方向は外側から内側へ向かい、ドメイン層が最も独立した状態を保ちます。
各層の役割
React のクリーンアーキテクチャでは、以下の 4 つの層に分離します。
| # | 層名 | 役割 | 具体例 |
|---|---|---|---|
| 1 | UI 層 | ユーザーインターフェースの表示と操作 | React コンポーネント、Hooks |
| 2 | アプリケーション層 | ユースケースの調整と実行 | カスタムフック、サービス |
| 3 | ドメイン層 | ビジネスロジックとルール | エンティティ、バリデーション |
| 4 | データ層 | 外部システムとの通信 | API クライアント、Repository |
課題
従来の React 開発では、以下のような課題が頻繁に発生します。
コンポーネントの肥大化
コンポーネント内にビジネスロジック、状態管理、API 呼び出しをすべて記述すると、1 つのファイルが数百行に及ぶことがあります。これにより、コードの理解が困難になり、バグの温床となります。
テストの困難さ
UI とロジックが密結合していると、ビジネスロジックのテストのために React コンポーネントをマウントする必要があり、テストが複雑で遅くなります。本来、ビジネスロジックは UI から独立してテストできるべきです。
変更の影響範囲の拡大
API のレスポンス形式が変わったとき、その影響がコンポーネント全体に波及します。また、UI フレームワークを変更する際にも、ビジネスロジックを書き直す必要が生じてしまいます。
以下の図は、責務分離がない場合の問題点を示しています。
mermaidflowchart LR
component["React Component<br/>(UI + Logic + API)"]
api["API"]
component -->|直接呼び出し| api
api -->|レスポンス| component
change["API 変更"] -.->|影響大| component
ui_change["UI 変更"] -.->|影響大| component
style component fill:#ffcccc
style change fill:#ff9999
style ui_change fill:#ff9999
このように、すべての変更がコンポーネントに影響を与える構造になっています。
再利用性の低下
ビジネスロジックがコンポーネントに埋め込まれていると、同じロジックを別の場所で使いたいときに、コードの重複が発生します。DRY(Don't Repeat Yourself)原則に反する状態になりがちです。
解決策
クリーンアーキテクチャの原則を React に適用することで、これらの課題を解決できます。重要なのは、各層の責務を明確にし、依存関係を一方向に保つことです。
依存性逆転の原則
クリーンアーキテクチャの核心は、依存性逆転の原則(DIP: Dependency Inversion Principle)にあります。上位の層(ドメイン層)が下位の層(データ層)に依存しないよう、インターフェース(TypeScript では型やクラス)を使って依存関係を逆転させます。
以下の図は、依存性逆転の仕組みを示しています。
mermaidflowchart TB
subgraph domain_layer["ドメイン層"]
use_case["UseCase"]
repository_interface["Repository Interface"]
end
subgraph data_layer["データ層"]
repository_impl["Repository 実装"]
api["API Client"]
end
use_case -->|依存| repository_interface
repository_impl -->|実装| repository_interface
repository_impl -->|使用| api
style repository_interface fill:#e1f5ff
style use_case fill:#fff4e1
style repository_impl fill:#e1ffe1
UseCase は具体的な実装ではなく、インターフェースに依存します。これにより、データ層の変更がドメイン層に影響を与えません。
層ごとのディレクトリ構成
プロジェクトのディレクトリ構造を層ごとに分けることで、責務の分離を物理的にも明確にします。
bashsrc/
├── presentation/ # UI 層
│ ├── components/
│ └── hooks/
├── application/ # アプリケーション層
│ └── usecases/
├── domain/ # ドメイン層
│ ├── entities/
│ ├── repositories/ # インターフェース
│ └── services/
└── infrastructure/ # データ層
├── api/
└── repositories/ # 実装
このディレクトリ構成により、各層の役割が一目で分かり、チーム開発でも混乱が少なくなります。
レイヤー間の通信ルール
各層は、以下のルールで通信します。
| # | ルール | 説明 |
|---|---|---|
| 1 | 外から内への依存のみ | UI 層はアプリケーション層に、アプリケーション層はドメイン層に依存する |
| 2 | インターフェースでの抽象化 | ドメイン層はインターフェースのみを公開し、実装の詳細を隠蔽する |
| 3 | データの変換 | データ層で取得した外部データは、ドメインエンティティに変換してから渡す |
具体例
実際のコード例を通じて、各層の実装方法を見ていきましょう。ユーザー管理機能を例に、クリーンアーキテクチャを実践します。
ドメイン層の実装
まず、ビジネスロジックの中心となるドメイン層から実装します。ドメイン層は、他のどの層にも依存しない独立した存在です。
エンティティの定義
エンティティは、ビジネスの核となるデータ構造とルールを表現します。
typescript// src/domain/entities/User.ts
/**
* ユーザーエンティティ
* ビジネスルールとデータ構造を定義
*/
export class User {
constructor(
public readonly id: string,
public readonly name: string,
public readonly email: string,
public readonly createdAt: Date
) {
// バリデーションはエンティティ内で実行
this.validate();
}
/**
* ユーザー情報のバリデーション
*/
private validate(): void {
if (!this.name || this.name.trim().length === 0) {
throw new Error('User name cannot be empty');
}
if (!this.isValidEmail(this.email)) {
throw new Error('Invalid email format');
}
}
このコードでは、User エンティティを定義し、コンストラクタ内でバリデーションを実行しています。
バリデーションロジック
バリデーションはビジネスルールの一部として、エンティティに含めます。
typescript /**
* メールアドレスの形式チェック
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* ユーザー名を変更する
* ビジネスルールを適用した安全な変更メソッド
*/
public changeName(newName: string): User {
// イミュータブルな更新
return new User(
this.id,
newName,
this.email,
this.createdAt
);
}
}
エンティティはイミュータブル(不変)に保ち、変更時は新しいインスタンスを返します。
リポジトリインターフェース
リポジトリのインターフェースをドメイン層で定義します。実装の詳細は隠蔽されています。
typescript// src/domain/repositories/UserRepository.ts
import { User } from '../entities/User';
/**
* ユーザーリポジトリのインターフェース
* ドメイン層で定義し、データ層で実装する
*/
export interface UserRepository {
/**
* ユーザー ID でユーザーを取得
*/
findById(id: string): Promise<User | null>;
/**
* すべてのユーザーを取得
*/
findAll(): Promise<User[]>;
/**
* ユーザーを保存
*/
save(user: User): Promise<void>;
/**
* ユーザーを削除
*/
delete(id: string): Promise<void>;
}
このインターフェースにより、ドメイン層はデータの取得方法の詳細を知る必要がありません。
データ層の実装
次に、外部システムとの通信を担当するデータ層を実装します。
API クライアント
API との通信を担当するクライアントを作成します。
typescript// src/infrastructure/api/UserApiClient.ts
/**
* API レスポンスの型定義
*/
export interface UserResponse {
id: string;
name: string;
email: string;
created_at: string; // API はスネークケース
}
/**
* ユーザー API クライアント
* 外部 API との通信を担当
*/
export class UserApiClient {
private baseUrl: string;
constructor(baseUrl: string = '/api') {
this.baseUrl = baseUrl;
}
/**
* ユーザー一覧を取得
*/
async fetchUsers(): Promise<UserResponse[]> {
const response = await fetch(`${this.baseUrl}/users`);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json();
}
API クライアントは、HTTP 通信の詳細を隠蔽し、型安全なインターフェースを提供します。
個別ユーザー取得
ID を指定してユーザーを取得するメソッドです。
typescript /**
* ID でユーザーを取得
*/
async fetchUserById(id: string): Promise<UserResponse | null> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json();
}
}
404 エラーは異常ではなく、ユーザーが存在しない正常なケースとして扱います。
リポジトリの実装
ドメイン層で定義したインターフェースを、データ層で実装します。
typescript// src/infrastructure/repositories/UserRepositoryImpl.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
import { UserApiClient, UserResponse } from '../api/UserApiClient';
/**
* ユーザーリポジトリの実装
* API レスポンスをドメインエンティティに変換
*/
export class UserRepositoryImpl implements UserRepository {
constructor(private apiClient: UserApiClient) {}
/**
* API レスポンスをエンティティに変換
*/
private toEntity(response: UserResponse): User {
return new User(
response.id,
response.name,
response.email,
new Date(response.created_at)
);
}
このメソッドは、API のデータ構造をドメインエンティティに変換する責務を持ちます。
データ取得メソッドの実装
インターフェースで定義したメソッドを実装します。
typescript /**
* ID でユーザーを取得
*/
async findById(id: string): Promise<User | null> {
const response = await this.apiClient.fetchUserById(id);
if (!response) {
return null;
}
return this.toEntity(response);
}
/**
* すべてのユーザーを取得
*/
async findAll(): Promise<User[]> {
const responses = await this.apiClient.fetchUsers();
return responses.map(response => this.toEntity(response));
}
/**
* ユーザーを保存(実装例)
*/
async save(user: User): Promise<void> {
// 実装は省略
console.log('Saving user:', user);
}
/**
* ユーザーを削除(実装例)
*/
async delete(id: string): Promise<void> {
// 実装は省略
console.log('Deleting user:', id);
}
}
リポジトリは、API クライアントを使ってデータを取得し、エンティティに変換して返します。
アプリケーション層の実装
アプリケーション層では、ユースケース(ユーザーの操作シナリオ)を実装します。
ユースケースの定義
ユーザー一覧を取得するユースケースを作成します。
typescript// src/application/usecases/GetAllUsersUseCase.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
/**
* ユーザー一覧取得ユースケース
* ビジネスルールの調整と実行を担当
*/
export class GetAllUsersUseCase {
constructor(private userRepository: UserRepository) {}
/**
* すべてのユーザーを取得して返す
*/
async execute(): Promise<User[]> {
try {
const users = await this.userRepository.findAll();
// 必要に応じて、ソートやフィルタリングなどの
// アプリケーション固有のロジックを追加
return users.sort(
(a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
);
} catch (error) {
// エラーハンドリングもユースケースの責務
console.error('Error fetching users:', error);
throw new Error('Failed to fetch users');
}
}
}
ユースケースは、リポジトリを使ってデータを取得し、必要に応じて加工します。
個別ユーザー取得ユースケース
特定のユーザーを取得するユースケースです。
typescript// src/application/usecases/GetUserByIdUseCase.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
/**
* ID によるユーザー取得ユースケース
*/
export class GetUserByIdUseCase {
constructor(private userRepository: UserRepository) {}
/**
* 指定された ID のユーザーを取得
*/
async execute(id: string): Promise<User> {
// 入力値のバリデーション
if (!id || id.trim().length === 0) {
throw new Error('User ID is required');
}
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error(`User not found: ${id}`);
}
return user;
}
}
ユースケースでは、入力値の検証やエラーハンドリングも行います。
UI 層の実装
最後に、React コンポーネントとカスタムフックで UI 層を実装します。
依存性注入の設定
各層のインスタンスを生成し、依存関係を組み立てます。
typescript// src/presentation/di/container.ts
import { UserApiClient } from '../../infrastructure/api/UserApiClient';
import { UserRepositoryImpl } from '../../infrastructure/repositories/UserRepositoryImpl';
import { GetAllUsersUseCase } from '../../application/usecases/GetAllUsersUseCase';
import { GetUserByIdUseCase } from '../../application/usecases/GetUserByIdUseCase';
/**
* 依存性注入コンテナ
* アプリケーション全体の依存関係を組み立て
*/
class DIContainer {
// インフラストラクチャ層
private userApiClient = new UserApiClient();
private userRepository = new UserRepositoryImpl(
this.userApiClient
);
// アプリケーション層
public getAllUsersUseCase = new GetAllUsersUseCase(
this.userRepository
);
public getUserByIdUseCase = new GetUserByIdUseCase(
this.userRepository
);
}
export const diContainer = new DIContainer();
DI コンテナにより、依存関係の組み立てを一箇所に集約できます。
カスタムフックの作成
ユースケースを React から使いやすくするためのカスタムフックを作成します。
typescript// src/presentation/hooks/useUsers.ts
import { useState, useEffect } from 'react';
import { User } from '../../domain/entities/User';
import { diContainer } from '../di/container';
/**
* ユーザー一覧を取得するカスタムフック
*/
export const useUsers = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
setError(null);
// ユースケースを実行
const result =
await diContainer.getAllUsersUseCase.execute();
setUsers(result);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Unknown error'
);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
return { users, loading, error };
};
カスタムフックは、ユースケースの実行と状態管理を担当します。
React コンポーネント
最後に、カスタムフックを使った React コンポーネントを作成します。
typescript// src/presentation/components/UserList.tsx
import React from 'react';
import { useUsers } from '../hooks/useUsers';
/**
* ユーザー一覧表示コンポーネント
* UI の表示のみに集中
*/
export const UserList: React.FC = () => {
const { users, loading, error } = useUsers();
if (loading) {
return <div>読み込み中...</div>;
}
if (error) {
return <div>エラー: {error}</div>;
}
return (
<div>
<h2>ユーザー一覧</h2>
<ul>
{users.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> ({user.email})
</li>
))}
</ul>
</div>
);
};
コンポーネントは、ビジネスロジックを持たず、UI の表示のみに専念します。
アーキテクチャ全体の流れ
以下の図は、実装した各層がどのように連携するかを示しています。
mermaidsequenceDiagram
participant UI as UserList<br/>Component
participant Hook as useUsers<br/>Hook
participant UC as GetAllUsers<br/>UseCase
participant Repo as UserRepository<br/>Impl
participant API as UserApi<br/>Client
UI->>Hook: レンダリング
Hook->>UC: execute()
UC->>Repo: findAll()
Repo->>API: fetchUsers()
API-->>Repo: UserResponse[]
Repo->>Repo: toEntity()
Repo-->>UC: User[]
UC->>UC: sort()
UC-->>Hook: User[]
Hook->>Hook: setState()
Hook-->>UI: 再レンダリング
このシーケンス図から、各層の責務が明確に分離され、依存関係が一方向であることが分かります。
テストの実装例
クリーンアーキテクチャの大きな利点は、テストのしやすさです。各層を独立してテストできます。
ドメイン層のテスト
エンティティのテストは、外部依存なしで実行できます。
typescript// src/domain/entities/__tests__/User.test.ts
import { User } from '../User';
describe('User Entity', () => {
it('正しい値でユーザーを作成できる', () => {
const user = new User(
'1',
'John Doe',
'john@example.com',
new Date()
);
expect(user.name).toBe('John Doe');
expect(user.email).toBe('john@example.com');
});
it('無効なメールアドレスでエラーをスローする', () => {
expect(() => {
new User(
'1',
'John Doe',
'invalid-email',
new Date()
);
}).toThrow('Invalid email format');
});
it('空の名前でエラーをスローする', () => {
expect(() => {
new User('1', '', 'john@example.com', new Date());
}).toThrow('User name cannot be empty');
});
});
ドメインロジックのテストは、純粋な関数のテストとして高速に実行できます。
ユースケースのテスト
ユースケースは、モックリポジトリを使ってテストします。
typescript// src/application/usecases/__tests__/GetAllUsersUseCase.test.ts
import { GetAllUsersUseCase } from '../GetAllUsersUseCase';
import { UserRepository } from '../../../domain/repositories/UserRepository';
import { User } from '../../../domain/entities/User';
// モックリポジトリの作成
const mockRepository: UserRepository = {
findAll: jest.fn(),
findById: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
describe('GetAllUsersUseCase', () => {
it('ユーザー一覧を作成日順に取得できる', async () => {
const users = [
new User(
'2',
'Bob',
'bob@example.com',
new Date('2024-02-01')
),
new User(
'1',
'Alice',
'alice@example.com',
new Date('2024-01-01')
),
];
(mockRepository.findAll as jest.Mock).mockResolvedValue(
users
);
const useCase = new GetAllUsersUseCase(mockRepository);
const result = await useCase.execute();
// 作成日順にソートされているか確認
expect(result[0].id).toBe('1');
expect(result[1].id).toBe('2');
});
});
モックを使うことで、データ層に依存せずにユースケースのロジックをテストできます。
UI 層のテスト
React コンポーネントは、React Testing Library でテストします。
typescript// src/presentation/components/__tests__/UserList.test.tsx
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { UserList } from '../UserList';
import { useUsers } from '../../hooks/useUsers';
// カスタムフックをモック
jest.mock('../../hooks/useUsers');
const mockUseUsers = useUsers as jest.MockedFunction<
typeof useUsers
>;
describe('UserList Component', () => {
it('読み込み中は「読み込み中...」を表示する', () => {
mockUseUsers.mockReturnValue({
users: [],
loading: true,
error: null,
});
render(<UserList />);
expect(
screen.getByText('読み込み中...')
).toBeInTheDocument();
});
it('ユーザー一覧を表示する', async () => {
const mockUsers = [
{
id: '1',
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date(),
},
{
id: '2',
name: 'Bob',
email: 'bob@example.com',
createdAt: new Date(),
},
] as any;
mockUseUsers.mockReturnValue({
users: mockUsers,
loading: false,
error: null,
});
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
});
UI のテストでは、ビジネスロジックではなく、表示とユーザー操作に集中できます。
まとめ
React におけるクリーンアーキテクチャの実践方法を、具体的なコード例とともに解説しました。UI・アプリケーション・ドメイン・データの各層を明確に分離することで、以下のメリットが得られます。
まず、テストしやすいコードベースになります。各層を独立してテストでき、モックを使った高速なユニットテストが可能です。次に、変更に強い設計が実現できます。UI フレームワークや API の変更が、ビジネスロジックに影響を与えません。
また、再利用性が向上します。ビジネスロジックが UI から独立しているため、別のコンポーネントや別のプラットフォームでも再利用できます。さらに、チーム開発がスムーズになります。各層の責務が明確なため、複数人での並行開発が容易です。
最初は層の分離が冗長に感じられるかもしれませんが、アプリケーションが成長するにつれて、その価値が実感できるでしょう。小規模なプロジェクトでも、将来の拡張を見据えてクリーンアーキテクチャを採用する価値は十分にあります。
ぜひ、次のプロジェクトでクリーンアーキテクチャを実践し、保守性の高い React アプリケーションを構築してください。
関連リンク
articleReact クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離
articleReact フック完全チートシート:useState から useTransition まで用途別早見表
articleReact 開発環境の作り方:Vite + TypeScript + ESLint + Prettier 完全セットアップ
articleReact とは? 2025 年版の特徴・強み・実務活用を一気に理解する完全解説
articleESLint を Yarn + TypeScript + React でゼロから構築:Flat Config 完全手順(macOS)
article【徹底比較】Preact vs React 2025:バンドル・FPS・メモリ・DX を総合評価
articleReact クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離
articleWebLLM vs サーバー推論 徹底比較:レイテンシ・コスト・スケールの実測レポート
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articlePython ORMs 実力検証:SQLAlchemy vs Tortoise vs Beanie の選び方
articleVite で Web Worker / SharedWorker を TypeScript でバンドルする初期設定
articlePrisma Accelerate と PgBouncer を比較:サーバレス時代の接続戦略ベンチ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来